diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 741cc43e..f5833e33 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -447,8 +447,13 @@ export class R2Service { async resolveConnection(npub) { if (!npub) return null; - // 1. Try StorageService - const pubkey = safeDecodeNpub(npub); + // 1. Try StorageService. Accept either a hex pubkey or an npub — some callers + // (e.g. ProfileEditController's image upload) pass hex, and safeDecodeNpub only + // decodes npub1… strings, so passing hex used to silently resolve no connection. + const trimmed = String(npub).trim(); + const pubkey = /^[0-9a-f]{64}$/i.test(trimmed) + ? trimmed.toLowerCase() + : safeDecodeNpub(trimmed); if (pubkey && storageService) { try { const connections = await storageService.listConnections(pubkey); diff --git a/tests/r2-resolve-connection-hex.test.mjs b/tests/r2-resolve-connection-hex.test.mjs new file mode 100644 index 00000000..673c3944 --- /dev/null +++ b/tests/r2-resolve-connection-hex.test.mjs @@ -0,0 +1,83 @@ +// #39: profile image/banner upload silently found "no storage" because +// ProfileEditController passes a HEX pubkey to r2Service.resolveConnection(), which +// only decoded npub1… strings (safeDecodeNpub) — so a hex pubkey resolved to null and +// the upload button stayed disabled even with storage configured. resolveConnection +// must accept a hex pubkey OR an npub. + +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import r2ServiceDefault from "../js/services/r2Service.js"; +import storageService from "../js/services/storageService.js"; + +const R2Service = r2ServiceDefault.constructor; +const HEX = "a".repeat(64); + +let originalWindow; + +beforeEach(() => { + originalWindow = globalThis.window; + // nip19.decode THROWS on anything that isn't a real npub — exactly how the live + // decoder behaves for a hex string. So if resolveConnection still works with hex, + // it's using the hex path, not npub decoding. + globalThis.window = { + location: { origin: "" }, + NostrTools: { + nip19: { + decode: (value) => { + if (typeof value === "string" && value.startsWith("npub1")) { + return { type: "npub", data: HEX }; + } + throw new Error("not an npub"); + }, + }, + }, + }; + + mock.method(storageService, "listConnections", async () => [ + { id: "backblaze_b2", provider: "backblaze_b2", meta: { defaultForUploads: true } }, + ]); + mock.method(storageService, "isUnlocked", () => true); + mock.method(storageService, "getConnection", async () => ({ + provider: "backblaze_b2", + accessKeyId: "b2-key", + secretAccessKey: "b2-secret", + endpoint: "https://s3.us-west-004.backblazeb2.com", + region: "us-west-004", + forcePathStyle: false, + meta: { + bucket: "bitvid", + provider: "backblaze_b2", + publicBaseUrl: "https://bitvid.s3.us-west-004.backblazeb2.com", + defaultForUploads: true, + }, + })); +}); + +afterEach(() => { + mock.restoreAll(); + globalThis.window = originalWindow; +}); + +describe("r2Service.resolveConnection accepts hex or npub", () => { + it("resolves the configured connection when passed a HEX pubkey (the upload bug)", async () => { + const svc = new R2Service(); + const conn = await svc.resolveConnection(HEX); + assert.ok(conn, "hex pubkey must resolve a connection, not null"); + assert.equal(conn.provider, "backblaze_b2"); + assert.equal(conn.bucket, "bitvid"); + assert.equal(conn.baseDomain, "https://bitvid.s3.us-west-004.backblazeb2.com"); + assert.equal(conn.accessKeyId, "b2-key"); + }); + + it("still resolves when passed an npub", async () => { + const svc = new R2Service(); + const conn = await svc.resolveConnection("npub1anything"); + assert.ok(conn); + assert.equal(conn.provider, "backblaze_b2"); + }); + + it("returns null for empty / non-key input", async () => { + const svc = new R2Service(); + assert.equal(await svc.resolveConnection(""), null); + }); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 66d37b13..cc030a99 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -903,12 +903,25 @@ Reported 2026-06-25. Relates to #17 (NIP-71 interop) / the bitvid→NIP-71 mirro the export carries both). Storage + profile-modal suites 69/69; build + lint clean. ### 39. Image uploads should prefer uploading to configured storage (UX) -- [ ] Anywhere the user adds an image, prefer letting them **upload an image file to - their configured storage** (Cloudflare R2 / S3 / B2 / Blossom) and auto-grab the - resulting URL — rather than pasting a link. First target: the **profile image + - banner** in Edit Profile (upload → store → auto-fill the URL). Reuse the existing - upload pipeline (`s3UploadService` / thumbnail upload) and apply the same pattern - to other image inputs (thumbnails already do some of this). +- [x] **Profile image + banner upload — FIXED 2026-06-30.** The UI was already wired + (`ProfileEditController.handleUpload`: resolve connection → `uploadFile` → auto-fill + the URL field), but it was **broken**: it passed a **hex** pubkey to + `r2Service.resolveConnection()`, which only decoded `npub1…` strings + (`safeDecodeNpub`) → hex resolved to `null` → "Storage configuration missing" and the + Upload button stayed disabled even with storage configured. (Thumbnails worked + because `mediaUploader` uses `listConnections(pubkey)` directly.) + **Fix:** `resolveConnection` now accepts a hex pubkey OR an npub. The rest of the + chain is already provider-agnostic — `resolveConnection` returns the + `defaultForUploads` connection (any provider) with its `endpoint`/`baseDomain`/ + `forcePathStyle`, `uploadFile` routes R2 vs generic-S3 by provider, and + `buildPublicUrl` builds the right URL — so this now works for R2 / B2 / Custom S3. + Test: `tests/r2-resolve-connection-hex.test.mjs` (3, with `nip19.decode` throwing on + hex to prove the hex path, not npub decoding). Build + lint clean. +- [ ] **VERIFY live:** unlock storage, Edit Profile → upload a picture/banner file → + uploads to the active provider's bucket and the URL auto-fills. +- [ ] **Extend the pattern** to any remaining image-URL inputs that still only accept a + pasted link (video thumbnail in upload/edit already uploads via `mediaUploader`). + Audit for others (e.g. future channel/branding image fields) and reuse the helper. ## Open — lower priority / infra