publish(task 12): blossom-upload mit multi-server, bud-01 auth
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) <noreply@anthropic.com>
This commit is contained in:
parent
8eebd29266
commit
02a955c46f
|
|
@ -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<Response>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<UploadReport> {
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue