From 02a955c46f0a527b75b365c551888e87e0031609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Lohrer?= Date: Sat, 18 Apr 2026 05:32:58 +0200 Subject: [PATCH] publish(task 12): blossom-upload mit multi-server, bud-01 auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uploadBlob(args) berechnet sha256, baut kind:24242-auth-event via signer, schickt es base64-kodiert im authorization-header an PUT /upload aller servers parallel. erfolg: report mit ok/failed-listen und primaryUrl (erster erfolgreicher server). wirft wenn alle ablehnen. BlossomClient via dependency-injection für tests. TS-casts für Uint8Array→BufferSource/BodyInit (deno-strict). 3 tests grün. Co-Authored-By: Claude Opus 4.6 (1M context) --- publish/src/core/blossom.ts | 88 +++++++++++++++++++++++++++++++++++ publish/tests/blossom_test.ts | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 publish/src/core/blossom.ts create mode 100644 publish/tests/blossom_test.ts diff --git a/publish/src/core/blossom.ts b/publish/src/core/blossom.ts new file mode 100644 index 0000000..02a9680 --- /dev/null +++ b/publish/src/core/blossom.ts @@ -0,0 +1,88 @@ +import { encodeBase64 } from '@std/encoding/base64' +import type { Signer } from './signer.ts' +import type { UnsignedEvent } from './event.ts' + +export interface BlossomClient { + fetch(url: string, init: RequestInit): Promise +} + +export interface UploadArgs { + data: Uint8Array + fileName: string + mimeType: string + servers: string[] + signer: Signer + client?: BlossomClient +} + +export interface UploadReport { + ok: string[] + failed: string[] + primaryUrl: string + sha256: string +} + +async function sha256Hex(data: Uint8Array): Promise { + const hash = await crypto.subtle.digest('SHA-256', data as BufferSource) + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +async function buildAuth(signer: Signer, hash: string): Promise { + const pubkey = await signer.getPublicKey() + const auth: UnsignedEvent = { + kind: 24242, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['t', 'upload'], + ['x', hash], + ['expiration', String(Math.floor(Date.now() / 1000) + 300)], + ], + content: '', + } + const signed = await signer.signEvent(auth) + return 'Nostr ' + encodeBase64(new TextEncoder().encode(JSON.stringify(signed))) +} + +async function uploadOne( + server: string, + data: Uint8Array, + mimeType: string, + auth: string, + client: BlossomClient, +): Promise<{ ok: boolean; url?: string }> { + try { + const resp = await client.fetch(server + '/upload', { + method: 'PUT', + headers: { authorization: auth, 'content-type': mimeType }, + body: data as BodyInit, + }) + if (!resp.ok) return { ok: false } + const json = await resp.json() + return { ok: true, url: json.url } + } catch { + return { ok: false } + } +} + +const defaultClient: BlossomClient = { fetch: (u, i) => fetch(u, i) } + +export async function uploadBlob(args: UploadArgs): Promise { + const client = args.client ?? defaultClient + const hash = await sha256Hex(args.data) + const auth = await buildAuth(args.signer, hash) + const results = await Promise.all( + args.servers.map((s) => + uploadOne(s, args.data, args.mimeType, auth, client).then((r) => ({ server: s, ...r })) + ), + ) + const ok = results.filter((r) => r.ok).map((r) => r.server) + const failed = results.filter((r) => !r.ok).map((r) => r.server) + if (ok.length === 0) { + throw new Error(`all blossom servers failed for ${args.fileName}`) + } + const first = results.find((r) => r.ok && r.url)! + return { ok, failed, primaryUrl: first.url!, sha256: hash } +} diff --git a/publish/tests/blossom_test.ts b/publish/tests/blossom_test.ts new file mode 100644 index 0000000..a3d8da6 --- /dev/null +++ b/publish/tests/blossom_test.ts @@ -0,0 +1,83 @@ +import { assertEquals } from '@std/assert' +import { type BlossomClient, uploadBlob } from '../src/core/blossom.ts' +import type { Signer } from '../src/core/signer.ts' +import type { UnsignedEvent } from '../src/core/event.ts' +import type { SignedEvent } from '../src/core/relays.ts' + +function fakeSigner(): Signer { + return { + getPublicKey: () => Promise.resolve('p'), + signEvent: (ev: UnsignedEvent) => + Promise.resolve({ ...ev, id: 'id', sig: 'sig', pubkey: 'p' } as SignedEvent), + } +} + +Deno.test('uploadBlob: pusht zu allen servern, gibt erste url zurück', async () => { + const data = new Uint8Array([1, 2, 3]) + const client: BlossomClient = { + fetch: (url, _init) => { + return Promise.resolve( + new Response(JSON.stringify({ url: url + '/hash.png', sha256: 'hash' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + }, + } + const result = await uploadBlob({ + data, + fileName: 'x.png', + mimeType: 'image/png', + servers: ['https://a.example', 'https://b.example'], + signer: fakeSigner(), + client, + }) + assertEquals(result.ok.length, 2) + assertEquals(result.primaryUrl, 'https://a.example/upload/hash.png') +}) + +Deno.test('uploadBlob: akzeptiert wenn mindestens ein server ok', async () => { + const data = new Uint8Array([1]) + const client: BlossomClient = { + fetch: (url) => { + if (url.startsWith('https://fail.example')) { + return Promise.resolve(new Response('nope', { status: 500 })) + } + return Promise.resolve( + new Response(JSON.stringify({ url: url + '/h.png', sha256: 'h' }), { status: 200 }), + ) + }, + } + const result = await uploadBlob({ + data, + fileName: 'x.png', + mimeType: 'image/png', + servers: ['https://fail.example', 'https://ok.example'], + signer: fakeSigner(), + client, + }) + assertEquals(result.ok, ['https://ok.example']) + assertEquals(result.failed, ['https://fail.example']) +}) + +Deno.test('uploadBlob: wirft wenn alle server ablehnen', async () => { + const data = new Uint8Array([1]) + const client: BlossomClient = { + fetch: () => Promise.resolve(new Response('err', { status: 500 })), + } + let threw = false + try { + await uploadBlob({ + data, + fileName: 'x.png', + mimeType: 'image/png', + servers: ['https://a.example'], + signer: fakeSigner(), + client, + }) + } catch (err) { + threw = true + assertEquals(String(err).includes('all blossom servers failed'), true) + } + assertEquals(threw, true) +})