Bump to v0.12.7 — fix NIP-96 upload endpoints, block SVG uploads

This commit is contained in:
Jure
2026-04-13 14:43:03 +02:00
parent c6ccb0989c
commit f3b92004f0
10 changed files with 63 additions and 30 deletions
+4 -6
View File
@@ -69,13 +69,11 @@ jobs:
> **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install.
### v0.12.6DM Polish & Image Fixes
### v0.12.7Upload Fixes
- **Clickable links in DMs** — URLs, nostr entities, and images now render properly inside direct messages; images show inline, links open in browser
- **Multi-image upload fix** — selecting multiple images in the article editor now inserts all of them (previously only the last one was kept)
- **Article image lightbox** — clicking a thumbnail in the editor strip opens a full-size lightbox overlay
- **Blossom image rendering** — images served from content-addressed storage (Blossom/NIP-96) with non-standard extensions (`.jp` etc.) now render inline instead of as plain links
- **nostr: entity parsing** — case-insensitive matching for `nostr:` prefix; broader extension support for image URLs
- **Image uploads fixed** — updated to current NIP-96 endpoints for nostr.build and files.sovbit.host; removed dead services (void.cat, nostrcheck.me)
- **SVG upload blocked** — SVG files are now rejected with a clear error message; they were silently uploading but rendering as broken images on all Nostr clients
- **NIP-98 payload hash** — upload auth headers now include the required SHA-256 body hash for strict NIP-96 servers
### v0.12.5 — UI Polish & Consistency
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## v0.12.7 — Upload Fixes (2026-04-13)
### Fixed
- Image uploads now work again — nostr.build and files.sovbit.host endpoints updated to their current NIP-96 URLs; removed void.cat (dead) and nostrcheck.me (returned broken URLs without file extensions)
- NIP-98 HTTP Auth header now includes the required SHA-256 payload hash, fixing rejections from strict NIP-96 servers
- SVG files are now rejected with a clear error message before upload in profile picture, banner, compose box, and inline reply — SVGs were silently uploading but rendering as broken images on all Nostr clients
## v0.12.6 — Rich Text Everywhere (2026-04-10)
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: hoornet <hoornet@users.noreply.github.com>
pkgname=vega-nostr
pkgver=0.12.6
pkgver=0.12.7
pkgrel=1
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
arch=('x86_64')
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "vega",
"private": true,
"version": "0.12.6",
"version": "0.12.7",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "vega"
version = "0.12.6"
version = "0.12.7"
description = "Cross-platform Nostr desktop client with Lightning integration"
authors = ["hoornet"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Vega",
"version": "0.12.6",
"version": "0.12.7",
"identifier": "com.hoornet.vega",
"build": {
"beforeDevCommand": "npm run dev",
+7 -2
View File
@@ -97,9 +97,14 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
const bytes = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "file";
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
setError("SVG files are not supported — please use PNG or JPG.");
setUploading(false);
return;
}
const mimeMap: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm",
webp: "image/webp", mp4: "video/mp4", webm: "video/webm",
mov: "video/quicktime", ogg: "video/ogg", m4v: "video/mp4",
};
const mimeType = mimeMap[ext] || "application/octet-stream";
@@ -160,7 +165,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
const selected = await open({
multiple: false,
filters: [
{ name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "mov", "ogg", "m4v"] },
{ name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "mp4", "webm", "mov", "ogg", "m4v"] },
],
});
if (!selected) return;
+4
View File
@@ -48,6 +48,10 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
};
const handleImageUpload = async (file: File) => {
if (file.type === "image/svg+xml") {
setUploadError("SVG files are not supported — please use PNG or JPG.");
return;
}
setUploading(true);
setUploadError(null);
try {
+5
View File
@@ -9,6 +9,11 @@ export function ImageField({ label, value, onChange }: { label: string; value: s
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.type === "image/svg+xml") {
setUploadError("SVG files are not supported — please use PNG or JPG.");
if (fileRef.current) fileRef.current.value = "";
return;
}
setUploading(true);
setUploadError(null);
try {
+32 -18
View File
@@ -2,17 +2,22 @@ import { fetch } from "@tauri-apps/plugin-http";
import { getNDK } from "./nostr";
import { NDKEvent } from "@nostr-dev-kit/ndk";
const UPLOAD_SERVICES = [
"https://nostr.build/api/v2/upload/files",
"https://void.cat/upload",
"https://nostrimg.com/api/upload",
interface UploadService {
url: string;
field: string; // multipart field name expected by the service
}
const UPLOAD_SERVICES: UploadService[] = [
{ url: "https://nostr.build/api/v2/nip96/upload", field: "file" },
{ url: "https://files.sovbit.host/api/v2/media", field: "file" },
{ url: "https://nostrimg.com/api/upload", field: "file" },
];
/**
* Create a NIP-98 HTTP Auth event (kind 27235) for a given URL and method.
* Returns a base64-encoded signed event for the Authorization header.
*/
async function createNip98AuthHeader(url: string, method: string): Promise<string> {
async function createNip98AuthHeader(url: string, method: string, body?: Uint8Array): Promise<string> {
const ndk = getNDK();
if (!ndk.signer) throw new Error("Not logged in — cannot sign NIP-98 auth");
@@ -24,6 +29,15 @@ async function createNip98AuthHeader(url: string, method: string): Promise<strin
["u", url],
["method", method.toUpperCase()],
];
if (body) {
const hashBuffer = await crypto.subtle.digest("SHA-256", body);
const hashHex = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
event.tags.push(["payload", hashHex]);
}
await event.sign();
const encoded = btoa(JSON.stringify(event.rawEvent()));
return `Nostr ${encoded}`;
@@ -70,47 +84,47 @@ function buildMultipart(fieldName: string, data: Uint8Array, fileName: string, m
* Upload raw bytes with NIP-98 auth. Tries nostr.build first, then fallbacks.
*/
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
const { body, contentType } = buildMultipart("file", bytes, fileName, mimeType);
const errors: string[] = [];
for (const serviceUrl of UPLOAD_SERVICES) {
for (const service of UPLOAD_SERVICES) {
const { body, contentType } = buildMultipart(service.field, bytes, fileName, mimeType);
try {
const headers: Record<string, string> = { "Content-Type": contentType };
try {
headers["Authorization"] = await createNip98AuthHeader(serviceUrl, "POST");
headers["Authorization"] = await createNip98AuthHeader(service.url, "POST", body);
} catch {
// If not logged in, try without auth (some services allow anonymous)
}
const resp = await fetch(serviceUrl, {
const resp = await fetch(service.url, {
method: "POST",
body,
headers,
});
if (!resp.ok) {
errors.push(`${serviceUrl}: HTTP ${resp.status}`);
errors.push(`${service.url}: HTTP ${resp.status}`);
continue;
}
const data = await resp.json();
// nostr.build response format
// NIP-96 standard response format
if (data.nip94_event?.tags) {
const urlTag = data.nip94_event.tags.find((t: string[]) => t[0] === "url");
if (urlTag?.[1]) return urlTag[1] as string;
}
// nostr.build legacy / plain url field
if (data.status === "success" && data.data?.[0]?.url) {
return data.data[0].url as string;
}
// void.cat response format
if (data.file?.url) {
return data.file.url as string;
}
// nostrimg.com response format
if (data.url) {
return data.url as string;
}
errors.push(`${serviceUrl}: no URL in response`);
errors.push(`${service.url}: no URL in response`);
} catch (err) {
errors.push(`${serviceUrl}: ${err}`);
errors.push(`${service.url}: ${err}`);
}
}