From f3b92004f0ee042f09426cef4d5ce36c0d810acb Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:43:03 +0200 Subject: [PATCH] =?UTF-8?q?Bump=20to=20v0.12.7=20=E2=80=94=20fix=20NIP-96?= =?UTF-8?q?=20upload=20endpoints,=20block=20SVG=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 10 +++--- CHANGELOG.md | 7 ++++ PKGBUILD | 2 +- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/feed/ComposeBox.tsx | 9 +++-- src/components/feed/InlineReplyBox.tsx | 4 +++ src/components/profile/ImageField.tsx | 5 +++ src/lib/upload.ts | 50 ++++++++++++++++---------- 10 files changed, 63 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58c283f..af8150d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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.6 — DM Polish & Image Fixes + ### v0.12.7 — Upload 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2437632..731a874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/PKGBUILD b/PKGBUILD index 922aa20..f1ff44d 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet 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') diff --git a/package.json b/package.json index 0c7f0b9..5205805 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vega", "private": true, - "version": "0.12.6", + "version": "0.12.7", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f0017c7..910ea24 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 169374e..f049dcc 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index b02938a..330fde7 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -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 = { 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; diff --git a/src/components/feed/InlineReplyBox.tsx b/src/components/feed/InlineReplyBox.tsx index e439c39..c56c316 100644 --- a/src/components/feed/InlineReplyBox.tsx +++ b/src/components/feed/InlineReplyBox.tsx @@ -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 { diff --git a/src/components/profile/ImageField.tsx b/src/components/profile/ImageField.tsx index eb509f6..e035f67 100644 --- a/src/components/profile/ImageField.tsx +++ b/src/components/profile/ImageField.tsx @@ -9,6 +9,11 @@ export function ImageField({ label, value, onChange }: { label: string; value: s const handleFile = async (e: React.ChangeEvent) => { 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 { diff --git a/src/lib/upload.ts b/src/lib/upload.ts index fff4bbd..74d1a00 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -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 { +async function createNip98AuthHeader(url: string, method: string, body?: Uint8Array): Promise { 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 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 { - 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 = { "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}`); } }