diff --git a/nipb7.ts b/nipb7.ts new file mode 100644 index 0000000..15bc2c5 --- /dev/null +++ b/nipb7.ts @@ -0,0 +1,203 @@ +import { sha256 } from '@noble/hashes/sha256' +import { EventTemplate } from './core.ts' +import { Signer } from './signer.ts' +import { bytesToHex } from './utils.ts' + +export type BlobDescriptor = { + url: string + sha256: string + size: number + type: string + uploaded: number +} + +export class BlossomClient { + private mediaserver: string + private signer: Signer + + constructor(mediaserver: string, signer: Signer) { + if (!mediaserver.startsWith('http')) { + mediaserver = 'https://' + mediaserver + } + this.mediaserver = mediaserver.replace(/\/$/, '') + '/' + this.signer = signer + } + + private async httpCall( + method: string, + url: string, + contentType?: string, + addAuthorization?: () => Promise, + body?: File | Blob, + result?: any, + ): Promise { + const headers: { [_: string]: string } = {} + + if (contentType) { + headers['Content-Type'] = contentType + } + + if (addAuthorization) { + const auth = await addAuthorization() + if (auth) { + headers['Authorization'] = auth + } + } + + const response = await fetch(this.mediaserver + url, { + method, + headers, + body, + }) + + if (response.status >= 300) { + const reason = response.headers.get('X-Reason') || response.statusText + throw new Error(`${url} returned an error (${response.status}): ${reason}`) + } + + if (result !== null && response.headers.get('content-type')?.includes('application/json')) { + return await response.json() + } + + return response + } + + private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise { + const now = Math.floor(Date.now() / 1000) + const event: EventTemplate = { + created_at: now, + kind: 24242, + content: 'blossom stuff', + tags: [['expiration', String(now + 60)]], + } + + if (modify) { + modify(event) + } + + try { + const signedEvent = await this.signer.signEvent(event) + const eventJson = JSON.stringify(signedEvent) + return 'Nostr ' + btoa(eventJson) + } catch (error) { + return '' + } + } + + private isValid32ByteHex(hash: string): boolean { + return /^[a-f0-9]{64}$/i.test(hash) + } + + async check(hash: string): Promise { + if (!this.isValid32ByteHex(hash)) { + throw new Error(`${hash} is not a valid 32-byte hex string`) + } + + try { + await this.httpCall('HEAD', hash) + } catch (error) { + throw new Error(`failed to check for ${hash}: ${error}`) + } + } + + async uploadBlob(file: File | Blob, contentType?: string): Promise { + const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer()))) + const actualContentType = contentType || file.type || 'application/octet-stream' + + const bd = await this.httpCall( + 'PUT', + 'upload', + actualContentType, + () => + this.authorizationHeader(evt => { + evt.tags.push(['t', 'upload']) + evt.tags.push(['x', hash]) + }), + file, + {}, + ) + + return bd + } + + async uploadFile(file: File): Promise { + return this.uploadBlob(file, file.type) + } + + async download(hash: string): Promise { + if (!this.isValid32ByteHex(hash)) { + throw new Error(`${hash} is not a valid 32-byte hex string`) + } + + const authHeader = await this.authorizationHeader(evt => { + evt.tags.push(['t', 'get']) + evt.tags.push(['x', hash]) + }) + + const response = await fetch(this.mediaserver + hash, { + method: 'GET', + headers: { + Authorization: authHeader, + }, + }) + + if (response.status >= 300) { + throw new Error(`${hash} is not present in ${this.mediaserver}: ${response.status}`) + } + + return await response.arrayBuffer() + } + + async downloadAsBlob(hash: string): Promise { + const arrayBuffer = await this.download(hash) + return new Blob([arrayBuffer]) + } + + async list(): Promise { + const pubkey = await this.signer.getPublicKey() + + if (!this.isValid32ByteHex(pubkey)) { + throw new Error(`pubkey ${pubkey} is not valid`) + } + + try { + const bds = await this.httpCall( + 'GET', + `list/${pubkey}`, + undefined, + () => + this.authorizationHeader(evt => { + evt.tags.push(['t', 'list']) + }), + undefined, + [], + ) + return bds + } catch (error) { + throw new Error(`failed to list blobs: ${error}`) + } + } + + async delete(hash: string): Promise { + if (!this.isValid32ByteHex(hash)) { + throw new Error(`${hash} is not a valid 32-byte hex string`) + } + + try { + await this.httpCall( + 'DELETE', + hash, + undefined, + () => + this.authorizationHeader(evt => { + evt.tags.push(['t', 'delete']) + evt.tags.push(['x', hash]) + }), + undefined, + null, + ) + } catch (error) { + throw new Error(`failed to delete ${hash}: ${error}`) + } + } +} diff --git a/signer.ts b/signer.ts new file mode 100644 index 0000000..4691192 --- /dev/null +++ b/signer.ts @@ -0,0 +1,6 @@ +import { EventTemplate, VerifiedEvent } from './core.ts' + +export interface Signer { + getPublicKey(): Promise + signEvent(event: EventTemplate): Promise +}