From ef14f6ee27b497fb8a8ef8bd29988006e7ae3426 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Sun, 23 Aug 2026 20:45:16 -0300 Subject: [PATCH] merge nipb0 into nipb7 (I got confused) and fix blossom atob() encoding error. --- jsr.json | 3 +- nipb0.test.ts | 67 ----- nipb0.ts | 760 ---------------------------------------------- nipb7.test.ts | 69 ++++- nipb7.ts | 810 ++++++++++++++++++++++++++++++++++++++++++-------- package.json | 8 +- 6 files changed, 761 insertions(+), 956 deletions(-) delete mode 100644 nipb0.test.ts delete mode 100644 nipb0.ts diff --git a/jsr.json b/jsr.json index a75650a..da96950 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@nostr/tools", - "version": "2.24.3", + "version": "2.25.0", "exports": { ".": "./index.ts", "./core": "./core.ts", @@ -44,7 +44,6 @@ "./nip94": "./nip94.ts", "./nip98": "./nip98.ts", "./nip99": "./nip99.ts", - "./nipb0": "./nipb0.ts", "./nipb7": "./nipb7.ts", "./fakejson": "./fakejson.ts", "./utils": "./utils.ts", diff --git a/nipb0.test.ts b/nipb0.test.ts deleted file mode 100644 index b6f79be..0000000 --- a/nipb0.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'bun:test' -import { getHashFromURL } from './nipb0.ts' - -const VALID_HASH = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' - -describe('getHashFromURL', () => { - it('extracts hash from plain URL string', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH}`)).toBe(VALID_HASH) - }) - - it('extracts hash from URL with extension', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH}.jpg`)).toBe(VALID_HASH) - }) - - it('extracts hash from URL with query string', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH}?foo=bar`)).toBe(VALID_HASH) - }) - - it('extracts hash from URL with fragment', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH}#section`)).toBe(VALID_HASH) - }) - - it('extracts hash from URL with query and fragment', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH}?foo=bar#section`)).toBe(VALID_HASH) - }) - - it('extracts hash from URL object', () => { - expect(getHashFromURL(new URL(`https://example.com/${VALID_HASH}`))).toBe(VALID_HASH) - }) - - it('extracts hash from URL object with pathname', () => { - const url = new URL(`https://example.com/${VALID_HASH}.png`) - expect(getHashFromURL(url)).toBe(VALID_HASH) - }) - - it('returns null when segment is too short', () => { - expect(getHashFromURL('https://example.com/abc')).toBeNull() - }) - - it('returns null when segment has non-hex characters', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH.slice(0, 63)}x`)).toBeNull() - }) - - it('returns null when extension is not at position 64', () => { - expect(getHashFromURL('https://example.com/short.jpg')).toBeNull() - }) - - it('returns null when uppercase hex chars are used', () => { - expect(getHashFromURL(`https://example.com/${VALID_HASH.toUpperCase()}`)).toBeNull() - }) - - it('returns null for URL without hash in path', () => { - expect(getHashFromURL('https://example.com/')).toBeNull() - }) - - it('handles localhost URL with hash', () => { - expect(getHashFromURL(`http://localhost:3000/${VALID_HASH}`)).toBe(VALID_HASH) - }) - - it('returns hash from nested path', () => { - expect(getHashFromURL(`https://example.com/files/${VALID_HASH}.jpg`)).toBe(VALID_HASH) - }) - - it('returns null for plain string without slash', () => { - expect(getHashFromURL('not-a-url')).toBeNull() - }) -}) diff --git a/nipb0.ts b/nipb0.ts deleted file mode 100644 index 036c47a..0000000 --- a/nipb0.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { sha256 } from '@noble/hashes/sha2.js' -import { bytesToHex } from '@noble/hashes/utils.js' -import type { EventTemplate } from './core.ts' -import type { Signer } from './signer.ts' -import { kinds } from './index.ts' -import { isHex32 } from './utils.ts' - -export type BlobDescriptor = { - url: string - sha256: string - size: number - type: string - uploaded: number -} - -export type UploadType = Blob | File | Buffer - -export type SignedEvent = EventTemplate & { - id: string - sig: string - pubkey: string -} - -export type AuthEventOptions = { - blobs?: string | string[] - servers?: string | string[] - message?: string - expiration?: number -} - -export function getBlobSize(blob: UploadType): number { - if ((typeof File !== 'undefined' && blob instanceof File) || blob instanceof Blob) { - return blob.size - } - return (blob as Buffer).length -} - -export function getBlobType(blob: UploadType): string | undefined { - if ((typeof File !== 'undefined' && blob instanceof File) || blob instanceof Blob) { - return blob.type || undefined - } - return undefined -} - -export async function computeBlobSha256(blob: UploadType): Promise { - let buffer: ArrayBuffer | Uint8Array - - if ((typeof File !== 'undefined' && blob instanceof File) || blob instanceof Blob) { - buffer = await blob.arrayBuffer() - } else { - buffer = blob - } - - const hash = sha256.create().update(new Uint8Array(buffer)).digest() - return bytesToHex(hash) -} - -// auth - -export function encodeAuthorizationHeader(event: SignedEvent): string { - const json = JSON.stringify(event) - const bytes = new TextEncoder().encode(json) - let binary = '' - for (const byte of bytes) binary += String.fromCharCode(byte) - return 'Nostr ' + btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') -} - -export function now(): number { - return Math.floor(Date.now() / 1000) -} - -export function oneHour(): number { - return now() + 3600 -} - -export function getAuthTagValues(auth: SignedEvent, tagName: string): string[] { - return auth.tags.filter(tag => tag[0] === tagName).map(tag => tag[1]) -} - -export function getAuthExpiration(auth: SignedEvent): number | undefined { - const expiration = auth.tags.find(tag => tag[0] === 'expiration')?.[1] - if (!expiration) return undefined - const timestamp = Number(expiration) - if (!Number.isFinite(timestamp)) return undefined - return timestamp -} - -export function isAuthExpired(auth: SignedEvent, timestamp: number = now()): boolean { - const expiration = getAuthExpiration(auth) - return expiration !== undefined && expiration <= timestamp -} - -export function normalizeServerTag(server: string | URL): string { - if (server instanceof URL) return server.hostname.toLowerCase() - if (URL.canParse(server)) return new URL(server).hostname.toLowerCase() - return server.toLowerCase() -} - -export function areServersEqual(a: string | URL, b: string | URL): boolean { - return normalizeServerTag(a) === normalizeServerTag(b) -} - -function normalizeServers(servers: string | string[]): string[] { - const values = Array.isArray(servers) ? servers : [servers] - return [...new Set(values.map(normalizeServerTag))] -} - -export async function createAuthEvent( - signer: (draft: EventTemplate) => Promise, - type: 'upload' | 'list' | 'delete' | 'get' | 'media', - options?: AuthEventOptions, -): Promise { - const draft: EventTemplate = { - created_at: now(), - kind: kinds.BlobsAuth, - content: options?.message ?? '', - tags: [ - ['t', type], - ['expiration', String(options?.expiration ?? oneHour())], - ], - } - - if (options?.blobs) { - const blobList = Array.isArray(options.blobs) ? options.blobs : [options.blobs] - const seen = new Set() - for (const blob of blobList) { - const hash = typeof blob === 'string' ? blob : await computeBlobSha256(blob) - if (!seen.has(hash)) { - draft.tags.push(['x', hash]) - seen.add(hash) - } - } - } - - if (options?.servers) { - for (const server of normalizeServers(options.servers)) { - draft.tags.push(['server', server]) - } - } - - return signer(draft) -} - -export type UploadAuthOptions = Omit & { type?: 'upload' | 'media' } - -export async function createUploadAuth( - signer: (draft: EventTemplate) => Promise, - blobs: string | string[], - options?: UploadAuthOptions, -): Promise { - return createAuthEvent(signer, options?.type ?? 'upload', { message: 'Upload Blob', ...options, blobs }) -} - -export type DownloadAuthOptions = Omit - -export async function createDownloadAuth( - signer: (draft: EventTemplate) => Promise, - hash: string, - options?: DownloadAuthOptions, -): Promise { - return createAuthEvent(signer, 'get', { message: 'Download Blob', ...options, blobs: [hash] }) -} - -export type MirrorAuthOptions = Omit - -export async function createMirrorAuth( - signer: (draft: EventTemplate) => Promise, - hash: string, - options?: MirrorAuthOptions, -): Promise { - return createAuthEvent(signer, 'upload', { message: 'Mirror Blob', ...options, blobs: [hash] }) -} - -export type ListAuthOptions = Omit - -export async function createListAuth( - signer: (draft: EventTemplate) => Promise, - options?: ListAuthOptions, -): Promise { - return createAuthEvent(signer, 'list', { message: 'List Blobs', ...options }) -} - -export type DeleteAuthOptions = Omit - -export async function createDeleteAuth( - signer: (draft: EventTemplate) => Promise, - hash: string, - options?: DeleteAuthOptions, -): Promise { - return createAuthEvent(signer, 'delete', { message: 'Delete Blob', ...options, blobs: [hash] }) -} - -// blossom URI - -export type BlossomURI = { - sha256: string - ext: string - servers: string[] - authors: string[] - size?: number -} - -export function parseBlossomURI(uri: string): BlossomURI { - if (!uri.startsWith('blossom:')) throw new Error('Invalid blossom URI: missing blossom: scheme') - const body = uri.slice('blossom:'.length) - const queryIndex = body.indexOf('?') - const path = queryIndex === -1 ? body : body.slice(0, queryIndex) - const query = queryIndex === -1 ? '' : body.slice(queryIndex + 1) - const dotIndex = path.indexOf('.') - if (dotIndex === -1) throw new Error('Invalid blossom URI: missing file extension') - const sha256 = path.slice(0, dotIndex) - const ext = path.slice(dotIndex + 1) - if (!isHex32(sha256)) throw new Error('Invalid blossom URI: invalid sha256 hash') - if (!ext) throw new Error('Invalid blossom URI: empty file extension') - const params = new URLSearchParams(query) - const servers = params.getAll('xs') - const authors = params.getAll('as') - const szValue = params.get('sz') - let size: number | undefined - if (szValue !== null) { - size = Number(szValue) - if (!Number.isFinite(size) || size <= 0 || Math.floor(size) !== size) { - throw new Error('Invalid blossom URI: sz must be a positive integer') - } - } - return { sha256, ext, servers, authors, size } -} - -export function buildBlossomURI(options: BlossomURI): string { - const params = new URLSearchParams() - for (const server of options.servers) params.append('xs', server) - for (const author of options.authors) params.append('as', author) - if (options.size !== undefined) params.append('sz', String(options.size)) - const query = params.toString() - return `blossom:${options.sha256}.${options.ext}${query ? '?' + query : ''}` -} - -export function blossomURIToURL(uri: string | BlossomURI): URL { - const str = typeof uri === 'string' ? uri : buildBlossomURI(uri) - return new URL(str) -} - -export function blossomURIFromURL(url: URL): BlossomURI { - if (url.protocol !== 'blossom:') throw new Error('Invalid blossom URL: expected blossom: protocol') - const path = url.pathname - const dotIndex = path.indexOf('.') - if (dotIndex === -1) throw new Error('Invalid blossom URL: missing file extension') - const sha256 = path.slice(0, dotIndex) - const ext = path.slice(dotIndex + 1) - if (!isHex32(sha256)) throw new Error('Invalid blossom URL: invalid sha256 hash') - if (!ext) throw new Error('Invalid blossom URL: empty file extension') - const servers = url.searchParams.getAll('xs') - const authors = url.searchParams.getAll('as') - const szValue = url.searchParams.get('sz') - let size: number | undefined - if (szValue !== null) { - size = Number(szValue) - if (!Number.isFinite(size) || size <= 0 || Math.floor(size) !== size) { - throw new Error('Invalid blossom URL: sz must be a positive integer') - } - } - return { sha256, ext, servers, authors, size } -} - -const commonMimeExtensions: Record = { - 'application/json': '.json', - 'application/pdf': '.pdf', - 'application/vnd.android.package-archive': '.apk', - 'application/vnd.sqlite3': '.sqlite3', - 'application/xml': '.xml', - 'audio/aac': '.aac', - 'audio/flac': '.flac', - 'audio/midi': '.midi', - 'audio/mp3': '.mp3', - 'audio/mpeg': '.mp3', - 'audio/mp4': '.m4a', - 'audio/ogg': '.ogg', - 'audio/wav': '.wav', - 'audio/webm': '.weba', - 'audio/x-aiff': '.aiff', - 'audio/x-m4a': '.m4a', - 'image/avif': '.avif', - 'image/gif': '.gif', - 'image/jpeg': '.jpg', - 'image/png': '.png', - 'image/svg+xml': '.svg', - 'image/webp': '.webp', - 'text/css': '.css', - 'text/csv': '.csv', - 'text/html': '.html', - 'text/javascript': '.js', - 'text/markdown': '.md', - 'text/plain': '.txt', - 'text/xml': '.xml', - 'video/mp2t': '.ts', - 'video/mp4': '.mp4', - 'video/ogg': '.ogv', - 'video/quicktime': '.mov', - 'video/webm': '.webm', - 'video/x-matroska': '.mkv', -} - -const commonExtensionMimes: Record = { - '.aac': 'audio/aac', - '.aiff': 'audio/x-aiff', - '.apk': 'application/vnd.android.package-archive', - '.avif': 'image/avif', - '.css': 'text/css; charset=utf-8', - '.csv': 'text/csv; charset=utf-8', - '.flac': 'audio/flac', - '.gif': 'image/gif', - '.html': 'text/html; charset=utf-8', - '.jpeg': 'image/jpeg', - '.jpg': 'image/jpeg', - '.js': 'text/javascript; charset=utf-8', - '.json': 'application/json', - '.m4a': 'audio/mp4', - '.md': 'text/markdown; charset=utf-8', - '.midi': 'audio/midi', - '.mkv': 'video/x-matroska', - '.mov': 'video/quicktime', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.oga': 'audio/ogg', - '.ogg': 'audio/ogg', - '.ogv': 'video/ogg', - '.pdf': 'application/pdf', - '.png': 'image/png', - '.sqlite3': 'application/vnd.sqlite3', - '.svg': 'image/svg+xml', - '.ts': 'video/mp2t', - '.txt': 'text/plain; charset=utf-8', - '.wav': 'audio/wav', - '.weba': 'audio/webm', - '.webm': 'video/webm', - '.webp': 'image/webp', - '.xml': 'application/xml', -} - -function normalizeMIMEType(mimetype: string): string { - const idx = mimetype.indexOf(';') - return idx >= 0 ? mimetype.slice(0, idx).trim().toLowerCase() : mimetype.trim().toLowerCase() -} - -export function getExtension(mimetype: string): string { - const normalized = normalizeMIMEType(mimetype) - if (!normalized) return '' - return commonMimeExtensions[normalized] ?? '' -} - -export function getMIMEType(ext: string): string { - if (!ext) return '' - ext = ext.trim().toLowerCase() - if (ext[0] !== '.') ext = '.' + ext - return commonExtensionMimes[ext] ?? '' -} - -export function getServersFromServerListEvent(event: { tags: string[][] }): URL[] { - const servers: URL[] = [] - for (const tag of event.tags) { - if (tag[0] === 'server' && tag[1]) { - try { - const url = new URL(tag[1]) - url.pathname = '/' - servers.push(url) - } catch {} - } - } - return servers -} - -export function getHashFromURL(url: string | URL): string | null { - // strip fragment, then query string - const path = typeof url === 'string' ? url.split('#', 1)[0].split('?', 1)[0] : url.pathname - - // get the last path segment - const lastSlash = path.lastIndexOf('/') - let segment = lastSlash === -1 ? path : path.slice(lastSlash + 1) - - // if there is an extension it should start at exactly the 64th character - const dotIndex = segment.indexOf('.') - if (dotIndex !== -1 && dotIndex !== 64) return null - - // the first 64 characters must be all lowercase hex - for (let i = 0; i < 64; i++) { - const c = segment.charCodeAt(i) - const isDigit = c >= 48 && c <= 57 // '0'-'9' - const isLowerHex = c >= 97 && c <= 102 // 'a'-'f' - if (!isDigit && !isLowerHex) return null - } - - return segment.slice(0, 64) -} - -export type UploadOptions = { - signal?: AbortSignal - auth?: SignedEvent | boolean - timeout?: number - onAuth?: (server: string, sha256: string) => Promise -} - -export async function uploadBlob(server: string, blob: Blob | File, opts?: UploadOptions): Promise { - const url = new URL('/upload', server).toString() - const sha256 = await computeBlobSha256(blob) - - const headers: Record = { - 'X-SHA-256': sha256, - 'Content-Type': getBlobType(blob) || 'application/octet-stream', - } - - if (opts?.auth) { - const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, sha256) : opts.auth - if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) - } - - const res = await fetch(url, { - method: 'PUT', - body: blob, - headers, - signal: opts?.signal, - }) - - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`upload returned error (${res.status}): ${reason}`) - } - - return res.json() -} - -export type DownloadOptions = { - signal?: AbortSignal - auth?: SignedEvent | boolean - timeout?: number - onAuth?: (server: string, sha256: string) => Promise -} - -export async function downloadBlob(server: string, hash: string, opts?: DownloadOptions): Promise { - const url = new URL('/' + hash, server).toString() - const headers: Record = {} - - if (opts?.auth) { - const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, hash) : opts.auth - if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) - } - - const res = await fetch(url, { headers, signal: opts?.signal }) - - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`${hash} download error (${res.status}): ${reason}`) - } - - return res -} - -export type ListOptions = { - signal?: AbortSignal - auth?: SignedEvent | boolean - timeout?: number - onAuth?: (server: string) => Promise - cursor?: string - limit?: number - since?: number - until?: number -} - -export async function listBlobs(server: string, pubkey: string, opts?: ListOptions): Promise { - const url = new URL('/list/' + pubkey, server) - if (opts?.cursor) url.searchParams.append('cursor', opts.cursor) - if (opts?.limit) url.searchParams.append('limit', String(opts.limit)) - if (opts?.since) url.searchParams.append('since', String(opts.since)) - if (opts?.until) url.searchParams.append('until', String(opts.until)) - - const headers: Record = {} - - if (opts?.auth) { - const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server) : opts.auth - if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) - } - - const res = await fetch(url.toString(), { headers, signal: opts?.signal }) - - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`list error (${res.status}): ${reason}`) - } - - return res.json() -} - -export async function* iterateBlobs( - server: string, - pubkey: string, - opts?: ListOptions, -): AsyncGenerator { - let cursor = opts?.cursor - while (true) { - const page = await listBlobs(server, pubkey, { ...opts, cursor }) - if (page.length === 0) return - yield page - if (opts?.limit && page.length < opts.limit) return - cursor = page[page.length - 1]?.sha256 - if (!cursor) return - } -} - -export type DeleteOptions = { - signal?: AbortSignal - auth?: SignedEvent | boolean - timeout?: number - onAuth?: (server: string, sha256: string) => Promise -} - -export async function deleteBlob(server: string, hash: string, opts?: DeleteOptions): Promise { - const url = new URL('/' + hash, server).toString() - const headers: Record = {} - - if (opts?.auth) { - const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, hash) : opts.auth - if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) - } - - const res = await fetch(url, { method: 'DELETE', headers, signal: opts?.signal }) - - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`delete error (${res.status}): ${reason}`) - } - - return res.ok -} - -export type HasBlobOptions = { - signal?: AbortSignal - timeout?: number -} - -export async function hasBlob(server: string, hash: string, opts?: HasBlobOptions): Promise { - const url = new URL('/' + hash, server) - try { - const res = await fetch(url.toString(), { method: 'HEAD', signal: opts?.signal }) - return res.status !== 404 - } catch { - return false - } -} - -export type MirrorOptions = { - signal?: AbortSignal - auth?: SignedEvent | boolean - timeout?: number - onAuth?: (server: string, sha256: string) => Promise -} - -export async function mirrorBlob(server: string, blob: BlobDescriptor, opts?: MirrorOptions): Promise { - const url = new URL('/mirror', server).toString() - - const headers: Record = { - 'Content-Type': 'application/json', - 'X-SHA-256': blob.sha256, - } - - if (opts?.auth) { - const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, blob.sha256) : opts.auth - if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) - } - - const res = await fetch(url, { - method: 'PUT', - body: JSON.stringify({ url: blob.url }), - headers, - signal: opts?.signal, - }) - - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`mirror error (${res.status}): ${reason}`) - } - - return res.json() -} - -export type ReportOptions = { - signal?: AbortSignal - timeout?: number - onError?: (server: string, error: Error) => void -} - -export async function reportBlobs( - servers: Iterable, - report: SignedEvent, - opts?: ReportOptions, -): Promise> { - if (report.kind !== 1984 || !report.tags.some(tag => tag[0] === 'x' && !!tag[1])) { - throw new Error('Invalid blob report event: must be kind 1984 with x tag') - } - - const results = new Map() - const body = JSON.stringify(report) - - for (const server of servers) { - try { - const res = await fetch(new URL('/report', server).toString(), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body, - signal: opts?.signal, - }) - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`report error (${res.status}): ${reason}`) - } - results.set(server, true) - } catch (error) { - if (opts?.onError && error instanceof Error) opts.onError(server, error) - } - } - - return results -} - -export class BlossomClient { - private mediaserver: string - - constructor( - mediaserver: string, - private signer: Signer, - ) { - if (!mediaserver.startsWith('http')) { - mediaserver = 'https://' + mediaserver - } - this.mediaserver = mediaserver.replace(/\/$/, '') + '/' - } - - getMediaServer(): string { - return this.mediaserver - } - - private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise { - const event = { - created_at: now(), - kind: kinds.BlobsAuth, - content: 'blossom stuff', - tags: [['expiration', String(now() + 60)]], - } - - modify?.(event) - - try { - const signedEvent = await this.signer.signEvent(event) - const json = JSON.stringify(signedEvent) - return 'Nostr ' + btoa(json) - } catch { - return '' - } - } - - private async httpCall( - method: string, - url: string, - contentType?: string, - addAuthorization?: () => Promise, - body?: File | Blob | string, - ): Promise { - const headers: Record = {} - - if (contentType) headers['Content-Type'] = contentType - if (addAuthorization) { - const auth = await addAuthorization() - if (auth) headers['Authorization'] = auth - } - - const res = await fetch(this.mediaserver + url, { method, headers, body }) - - if (res.status >= 300) { - const reason = res.headers.get('X-Reason') || res.statusText - throw new Error(`${url} returned error (${res.status}): ${reason}`) - } - - if (res.headers.get('content-type')?.includes('application/json')) { - return res.json() - } - - return res - } - - async uploadBlob(file: Blob | File, contentType?: string): Promise { - const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer()))) - const actualContentType = contentType || getBlobType(file) || 'application/octet-stream' - - return this.httpCall( - 'PUT', - 'upload', - actualContentType, - () => - this.authorizationHeader(evt => { - evt.tags.push(['t', 'upload'], ['x', hash]) - }), - file, - ) - } - - async download(hash: string): Promise { - const authHeader = await this.authorizationHeader(evt => { - evt.tags.push(['t', 'get'], ['x', hash]) - }) - - const res = await fetch(this.mediaserver + hash, { - method: 'GET', - headers: { Authorization: authHeader }, - }) - - if (res.status >= 300) { - throw new Error(`${hash} not present on ${this.mediaserver}: ${res.status}`) - } - - return res.arrayBuffer() - } - - async downloadAsBlob(hash: string): Promise { - return new Blob([await this.download(hash)]) - } - - async list(): Promise { - const pubkey = await this.signer.getPublicKey() - return this.httpCall('GET', `list/${pubkey}`, undefined, () => - this.authorizationHeader(evt => { - evt.tags.push(['t', 'list']) - }), - ) - } - - async delete(hash: string): Promise { - await this.httpCall('DELETE', hash, undefined, () => - this.authorizationHeader(evt => { - evt.tags.push(['t', 'delete'], ['x', hash]) - }), - ) - } - - async check(hash: string): Promise { - if (!isHex32(hash)) throw new Error(`${hash} is not valid 32-byte hex`) - await this.httpCall('HEAD', hash) - } - - async mirror(remoteBlobURL: string): Promise { - const hash = remoteBlobURL.split('/').pop()?.split('.')[0] || '' - return this.httpCall( - 'PUT', - 'mirror', - 'application/json', - () => - this.authorizationHeader(evt => { - evt.tags.push(['t', 'upload'], ['x', hash]) - }), - JSON.stringify({ url: remoteBlobURL }), - ) - } -} diff --git a/nipb7.test.ts b/nipb7.test.ts index 26475ab..93cec03 100644 --- a/nipb7.test.ts +++ b/nipb7.test.ts @@ -1,5 +1,5 @@ -import { test, expect } from 'bun:test' -import { BlossomClient } from './nipb7.ts' +import { describe, expect, it, test } from 'bun:test' +import { BlossomClient, getHashFromURL } from './nipb7.ts' import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex } from './utils.ts' import { PlainKeySigner } from './signer.ts' @@ -53,3 +53,68 @@ test('blossom', async () => { // delete await client.delete(expectedHash) }) + +const VALID_HASH = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + +describe('getHashFromURL', () => { + it('extracts hash from plain URL string', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH}`)).toBe(VALID_HASH) + }) + + it('extracts hash from URL with extension', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH}.jpg`)).toBe(VALID_HASH) + }) + + it('extracts hash from URL with query string', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH}?foo=bar`)).toBe(VALID_HASH) + }) + + it('extracts hash from URL with fragment', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH}#section`)).toBe(VALID_HASH) + }) + + it('extracts hash from URL with query and fragment', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH}?foo=bar#section`)).toBe(VALID_HASH) + }) + + it('extracts hash from URL object', () => { + expect(getHashFromURL(new URL(`https://example.com/${VALID_HASH}`))).toBe(VALID_HASH) + }) + + it('extracts hash from URL object with pathname', () => { + const url = new URL(`https://example.com/${VALID_HASH}.png`) + expect(getHashFromURL(url)).toBe(VALID_HASH) + }) + + it('returns null when segment is too short', () => { + expect(getHashFromURL('https://example.com/abc')).toBeNull() + }) + + it('returns null when segment has non-hex characters', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH.slice(0, 63)}x`)).toBeNull() + }) + + it('returns null when extension is not at position 64', () => { + expect(getHashFromURL('https://example.com/short.jpg')).toBeNull() + }) + + it('returns null when uppercase hex chars are used', () => { + expect(getHashFromURL(`https://example.com/${VALID_HASH.toUpperCase()}`)).toBeNull() + }) + + it('returns null for URL without hash in path', () => { + expect(getHashFromURL('https://example.com/')).toBeNull() + }) + + it('handles localhost URL with hash', () => { + expect(getHashFromURL(`http://localhost:3000/${VALID_HASH}`)).toBe(VALID_HASH) + }) + + it('returns hash from nested path', () => { + expect(getHashFromURL(`https://example.com/files/${VALID_HASH}.jpg`)).toBe(VALID_HASH) + }) + + it('returns null for plain string without slash', () => { + expect(getHashFromURL('not-a-url')).toBeNull() + }) +}) diff --git a/nipb7.ts b/nipb7.ts index 5753f46..118d905 100644 --- a/nipb7.ts +++ b/nipb7.ts @@ -1,7 +1,9 @@ import { sha256 } from '@noble/hashes/sha2.js' -import { EventTemplate } from './core.ts' -import { Signer } from './signer.ts' -import { bytesToHex, isHex32 } from './utils.ts' +import { bytesToHex } from '@noble/hashes/utils.js' +import type { EventTemplate } from './core.ts' +import type { Signer } from './signer.ts' +import { kinds } from './index.ts' +import { isHex32 } from './utils.ts' export type BlobDescriptor = { url: string @@ -11,16 +13,647 @@ export type BlobDescriptor = { uploaded: number } +export type UploadType = Blob | File | Buffer + +export type SignedEvent = EventTemplate & { + id: string + sig: string + pubkey: string +} + +export type AuthEventOptions = { + blobs?: string | string[] + servers?: string | string[] + message?: string + expiration?: number +} + +export function getBlobSize(blob: UploadType): number { + if ((typeof File !== 'undefined' && blob instanceof File) || blob instanceof Blob) { + return blob.size + } + return (blob as Buffer).length +} + +export function getBlobType(blob: UploadType): string | undefined { + if ((typeof File !== 'undefined' && blob instanceof File) || blob instanceof Blob) { + return blob.type || undefined + } + return undefined +} + +export async function computeBlobSha256(blob: UploadType): Promise { + let buffer: ArrayBuffer | Uint8Array + + if ((typeof File !== 'undefined' && blob instanceof File) || blob instanceof Blob) { + buffer = await blob.arrayBuffer() + } else { + buffer = blob + } + + const hash = sha256.create().update(new Uint8Array(buffer)).digest() + return bytesToHex(hash) +} + +// auth + +export function encodeAuthorizationHeader(event: SignedEvent): string { + const json = JSON.stringify(event) + const bytes = new TextEncoder().encode(json) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return 'Nostr ' + btoa(binary) +} + +export function now(): number { + return Math.floor(Date.now() / 1000) +} + +export function oneHour(): number { + return now() + 3600 +} + +export function getAuthTagValues(auth: SignedEvent, tagName: string): string[] { + return auth.tags.filter(tag => tag[0] === tagName).map(tag => tag[1]) +} + +export function getAuthExpiration(auth: SignedEvent): number | undefined { + const expiration = auth.tags.find(tag => tag[0] === 'expiration')?.[1] + if (!expiration) return undefined + const timestamp = Number(expiration) + if (!Number.isFinite(timestamp)) return undefined + return timestamp +} + +export function isAuthExpired(auth: SignedEvent, timestamp: number = now()): boolean { + const expiration = getAuthExpiration(auth) + return expiration !== undefined && expiration <= timestamp +} + +export function normalizeServerTag(server: string | URL): string { + if (server instanceof URL) return server.hostname.toLowerCase() + if (URL.canParse(server)) return new URL(server).hostname.toLowerCase() + return server.toLowerCase() +} + +export function areServersEqual(a: string | URL, b: string | URL): boolean { + return normalizeServerTag(a) === normalizeServerTag(b) +} + +function normalizeServers(servers: string | string[]): string[] { + const values = Array.isArray(servers) ? servers : [servers] + return [...new Set(values.map(normalizeServerTag))] +} + +export async function createAuthEvent( + signer: (draft: EventTemplate) => Promise, + type: 'upload' | 'list' | 'delete' | 'get' | 'media', + options?: AuthEventOptions, +): Promise { + const draft: EventTemplate = { + created_at: now(), + kind: kinds.BlobsAuth, + content: options?.message ?? '', + tags: [ + ['t', type], + ['expiration', String(options?.expiration ?? oneHour())], + ], + } + + if (options?.blobs) { + const blobList = Array.isArray(options.blobs) ? options.blobs : [options.blobs] + const seen = new Set() + for (const blob of blobList) { + const hash = typeof blob === 'string' ? blob : await computeBlobSha256(blob) + if (!seen.has(hash)) { + draft.tags.push(['x', hash]) + seen.add(hash) + } + } + } + + if (options?.servers) { + for (const server of normalizeServers(options.servers)) { + draft.tags.push(['server', server]) + } + } + + return signer(draft) +} + +export type UploadAuthOptions = Omit & { type?: 'upload' | 'media' } + +export async function createUploadAuth( + signer: (draft: EventTemplate) => Promise, + blobs: string | string[], + options?: UploadAuthOptions, +): Promise { + return createAuthEvent(signer, options?.type ?? 'upload', { message: 'Upload Blob', ...options, blobs }) +} + +export type DownloadAuthOptions = Omit + +export async function createDownloadAuth( + signer: (draft: EventTemplate) => Promise, + hash: string, + options?: DownloadAuthOptions, +): Promise { + return createAuthEvent(signer, 'get', { message: 'Download Blob', ...options, blobs: [hash] }) +} + +export type MirrorAuthOptions = Omit + +export async function createMirrorAuth( + signer: (draft: EventTemplate) => Promise, + hash: string, + options?: MirrorAuthOptions, +): Promise { + return createAuthEvent(signer, 'upload', { message: 'Mirror Blob', ...options, blobs: [hash] }) +} + +export type ListAuthOptions = Omit + +export async function createListAuth( + signer: (draft: EventTemplate) => Promise, + options?: ListAuthOptions, +): Promise { + return createAuthEvent(signer, 'list', { message: 'List Blobs', ...options }) +} + +export type DeleteAuthOptions = Omit + +export async function createDeleteAuth( + signer: (draft: EventTemplate) => Promise, + hash: string, + options?: DeleteAuthOptions, +): Promise { + return createAuthEvent(signer, 'delete', { message: 'Delete Blob', ...options, blobs: [hash] }) +} + +// blossom URI + +export type BlossomURI = { + sha256: string + ext: string + servers: string[] + authors: string[] + size?: number +} + +export function parseBlossomURI(uri: string): BlossomURI { + if (!uri.startsWith('blossom:')) throw new Error('Invalid blossom URI: missing blossom: scheme') + const body = uri.slice('blossom:'.length) + const queryIndex = body.indexOf('?') + const path = queryIndex === -1 ? body : body.slice(0, queryIndex) + const query = queryIndex === -1 ? '' : body.slice(queryIndex + 1) + const dotIndex = path.indexOf('.') + if (dotIndex === -1) throw new Error('Invalid blossom URI: missing file extension') + const sha256 = path.slice(0, dotIndex) + const ext = path.slice(dotIndex + 1) + if (!isHex32(sha256)) throw new Error('Invalid blossom URI: invalid sha256 hash') + if (!ext) throw new Error('Invalid blossom URI: empty file extension') + const params = new URLSearchParams(query) + const servers = params.getAll('xs') + const authors = params.getAll('as') + const szValue = params.get('sz') + let size: number | undefined + if (szValue !== null) { + size = Number(szValue) + if (!Number.isFinite(size) || size <= 0 || Math.floor(size) !== size) { + throw new Error('Invalid blossom URI: sz must be a positive integer') + } + } + return { sha256, ext, servers, authors, size } +} + +export function buildBlossomURI(options: BlossomURI): string { + const params = new URLSearchParams() + for (const server of options.servers) params.append('xs', server) + for (const author of options.authors) params.append('as', author) + if (options.size !== undefined) params.append('sz', String(options.size)) + const query = params.toString() + return `blossom:${options.sha256}.${options.ext}${query ? '?' + query : ''}` +} + +export function blossomURIToURL(uri: string | BlossomURI): URL { + const str = typeof uri === 'string' ? uri : buildBlossomURI(uri) + return new URL(str) +} + +export function blossomURIFromURL(url: URL): BlossomURI { + if (url.protocol !== 'blossom:') throw new Error('Invalid blossom URL: expected blossom: protocol') + const path = url.pathname + const dotIndex = path.indexOf('.') + if (dotIndex === -1) throw new Error('Invalid blossom URL: missing file extension') + const sha256 = path.slice(0, dotIndex) + const ext = path.slice(dotIndex + 1) + if (!isHex32(sha256)) throw new Error('Invalid blossom URL: invalid sha256 hash') + if (!ext) throw new Error('Invalid blossom URL: empty file extension') + const servers = url.searchParams.getAll('xs') + const authors = url.searchParams.getAll('as') + const szValue = url.searchParams.get('sz') + let size: number | undefined + if (szValue !== null) { + size = Number(szValue) + if (!Number.isFinite(size) || size <= 0 || Math.floor(size) !== size) { + throw new Error('Invalid blossom URL: sz must be a positive integer') + } + } + return { sha256, ext, servers, authors, size } +} + +const commonMimeExtensions: Record = { + 'application/json': '.json', + 'application/pdf': '.pdf', + 'application/vnd.android.package-archive': '.apk', + 'application/vnd.sqlite3': '.sqlite3', + 'application/xml': '.xml', + 'audio/aac': '.aac', + 'audio/flac': '.flac', + 'audio/midi': '.midi', + 'audio/mp3': '.mp3', + 'audio/mpeg': '.mp3', + 'audio/mp4': '.m4a', + 'audio/ogg': '.ogg', + 'audio/wav': '.wav', + 'audio/webm': '.weba', + 'audio/x-aiff': '.aiff', + 'audio/x-m4a': '.m4a', + 'image/avif': '.avif', + 'image/gif': '.gif', + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/svg+xml': '.svg', + 'image/webp': '.webp', + 'text/css': '.css', + 'text/csv': '.csv', + 'text/html': '.html', + 'text/javascript': '.js', + 'text/markdown': '.md', + 'text/plain': '.txt', + 'text/xml': '.xml', + 'video/mp2t': '.ts', + 'video/mp4': '.mp4', + 'video/ogg': '.ogv', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-matroska': '.mkv', +} + +const commonExtensionMimes: Record = { + '.aac': 'audio/aac', + '.aiff': 'audio/x-aiff', + '.apk': 'application/vnd.android.package-archive', + '.avif': 'image/avif', + '.css': 'text/css; charset=utf-8', + '.csv': 'text/csv; charset=utf-8', + '.flac': 'audio/flac', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.m4a': 'audio/mp4', + '.md': 'text/markdown; charset=utf-8', + '.midi': 'audio/midi', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.ogv': 'video/ogg', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.sqlite3': 'application/vnd.sqlite3', + '.svg': 'image/svg+xml', + '.ts': 'video/mp2t', + '.txt': 'text/plain; charset=utf-8', + '.wav': 'audio/wav', + '.weba': 'audio/webm', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.xml': 'application/xml', +} + +function normalizeMIMEType(mimetype: string): string { + const idx = mimetype.indexOf(';') + return idx >= 0 ? mimetype.slice(0, idx).trim().toLowerCase() : mimetype.trim().toLowerCase() +} + +export function getExtension(mimetype: string): string { + const normalized = normalizeMIMEType(mimetype) + if (!normalized) return '' + return commonMimeExtensions[normalized] ?? '' +} + +export function getMIMEType(ext: string): string { + if (!ext) return '' + ext = ext.trim().toLowerCase() + if (ext[0] !== '.') ext = '.' + ext + return commonExtensionMimes[ext] ?? '' +} + +export function getServersFromServerListEvent(event: { tags: string[][] }): URL[] { + const servers: URL[] = [] + for (const tag of event.tags) { + if (tag[0] === 'server' && tag[1]) { + try { + const url = new URL(tag[1]) + url.pathname = '/' + servers.push(url) + } catch {} + } + } + return servers +} + +export function getHashFromURL(url: string | URL): string | null { + // strip fragment, then query string + const path = typeof url === 'string' ? url.split('#', 1)[0].split('?', 1)[0] : url.pathname + + // get the last path segment + const lastSlash = path.lastIndexOf('/') + let segment = lastSlash === -1 ? path : path.slice(lastSlash + 1) + + // if there is an extension it should start at exactly the 64th character + const dotIndex = segment.indexOf('.') + if (dotIndex !== -1 && dotIndex !== 64) return null + + // the first 64 characters must be all lowercase hex + for (let i = 0; i < 64; i++) { + const c = segment.charCodeAt(i) + const isDigit = c >= 48 && c <= 57 // '0'-'9' + const isLowerHex = c >= 97 && c <= 102 // 'a'-'f' + if (!isDigit && !isLowerHex) return null + } + + return segment.slice(0, 64) +} + +export type UploadOptions = { + signal?: AbortSignal + auth?: SignedEvent | boolean + timeout?: number + onAuth?: (server: string, sha256: string) => Promise +} + +export async function uploadBlob(server: string, blob: Blob | File, opts?: UploadOptions): Promise { + const url = new URL('/upload', server).toString() + const sha256 = await computeBlobSha256(blob) + + const headers: Record = { + 'X-SHA-256': sha256, + 'Content-Type': getBlobType(blob) || 'application/octet-stream', + } + + if (opts?.auth) { + const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, sha256) : opts.auth + if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) + } + + const res = await fetch(url, { + method: 'PUT', + body: blob, + headers, + signal: opts?.signal, + }) + + if (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`upload returned error (${res.status}): ${reason}`) + } + + return res.json() +} + +export type DownloadOptions = { + signal?: AbortSignal + auth?: SignedEvent | boolean + timeout?: number + onAuth?: (server: string, sha256: string) => Promise +} + +export async function downloadBlob(server: string, hash: string, opts?: DownloadOptions): Promise { + const url = new URL('/' + hash, server).toString() + const headers: Record = {} + + if (opts?.auth) { + const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, hash) : opts.auth + if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) + } + + const res = await fetch(url, { headers, signal: opts?.signal }) + + if (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`${hash} download error (${res.status}): ${reason}`) + } + + return res +} + +export type ListOptions = { + signal?: AbortSignal + auth?: SignedEvent | boolean + timeout?: number + onAuth?: (server: string) => Promise + cursor?: string + limit?: number + since?: number + until?: number +} + +export async function listBlobs(server: string, pubkey: string, opts?: ListOptions): Promise { + const url = new URL('/list/' + pubkey, server) + if (opts?.cursor) url.searchParams.append('cursor', opts.cursor) + if (opts?.limit) url.searchParams.append('limit', String(opts.limit)) + if (opts?.since) url.searchParams.append('since', String(opts.since)) + if (opts?.until) url.searchParams.append('until', String(opts.until)) + + const headers: Record = {} + + if (opts?.auth) { + const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server) : opts.auth + if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) + } + + const res = await fetch(url.toString(), { headers, signal: opts?.signal }) + + if (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`list error (${res.status}): ${reason}`) + } + + return res.json() +} + +export async function* iterateBlobs( + server: string, + pubkey: string, + opts?: ListOptions, +): AsyncGenerator { + let cursor = opts?.cursor + while (true) { + const page = await listBlobs(server, pubkey, { ...opts, cursor }) + if (page.length === 0) return + yield page + if (opts?.limit && page.length < opts.limit) return + cursor = page[page.length - 1]?.sha256 + if (!cursor) return + } +} + +export type DeleteOptions = { + signal?: AbortSignal + auth?: SignedEvent | boolean + timeout?: number + onAuth?: (server: string, sha256: string) => Promise +} + +export async function deleteBlob(server: string, hash: string, opts?: DeleteOptions): Promise { + const url = new URL('/' + hash, server).toString() + const headers: Record = {} + + if (opts?.auth) { + const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, hash) : opts.auth + if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) + } + + const res = await fetch(url, { method: 'DELETE', headers, signal: opts?.signal }) + + if (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`delete error (${res.status}): ${reason}`) + } + + return res.ok +} + +export type HasBlobOptions = { + signal?: AbortSignal + timeout?: number +} + +export async function hasBlob(server: string, hash: string, opts?: HasBlobOptions): Promise { + const url = new URL('/' + hash, server) + try { + const res = await fetch(url.toString(), { method: 'HEAD', signal: opts?.signal }) + return res.status !== 404 + } catch { + return false + } +} + +export type MirrorOptions = { + signal?: AbortSignal + auth?: SignedEvent | boolean + timeout?: number + onAuth?: (server: string, sha256: string) => Promise +} + +export async function mirrorBlob(server: string, blob: BlobDescriptor, opts?: MirrorOptions): Promise { + const url = new URL('/mirror', server).toString() + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-SHA-256': blob.sha256, + } + + if (opts?.auth) { + const authEvent = typeof opts.auth === 'boolean' ? await opts.onAuth?.(server, blob.sha256) : opts.auth + if (authEvent) headers['Authorization'] = encodeAuthorizationHeader(authEvent) + } + + const res = await fetch(url, { + method: 'PUT', + body: JSON.stringify({ url: blob.url }), + headers, + signal: opts?.signal, + }) + + if (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`mirror error (${res.status}): ${reason}`) + } + + return res.json() +} + +export type ReportOptions = { + signal?: AbortSignal + timeout?: number + onError?: (server: string, error: Error) => void +} + +export async function reportBlobs( + servers: Iterable, + report: SignedEvent, + opts?: ReportOptions, +): Promise> { + if (report.kind !== 1984 || !report.tags.some(tag => tag[0] === 'x' && !!tag[1])) { + throw new Error('Invalid blob report event: must be kind 1984 with x tag') + } + + const results = new Map() + const body = JSON.stringify(report) + + for (const server of servers) { + try { + const res = await fetch(new URL('/report', server).toString(), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body, + signal: opts?.signal, + }) + if (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`report error (${res.status}): ${reason}`) + } + results.set(server, true) + } catch (error) { + if (opts?.onError && error instanceof Error) opts.onError(server, error) + } + } + + return results +} + export class BlossomClient { private mediaserver: string - private signer: Signer - constructor(mediaserver: string, signer: Signer) { + constructor( + mediaserver: string, + private signer: Signer, + ) { if (!mediaserver.startsWith('http')) { mediaserver = 'https://' + mediaserver } this.mediaserver = mediaserver.replace(/\/$/, '') + '/' - this.signer = signer + } + + getMediaServer(): string { + return this.mediaserver + } + + private async authorizationHeader(modify?: (event: EventTemplate) => void): Promise { + const event = { + created_at: now(), + kind: kinds.BlobsAuth, + content: 'blossom stuff', + tags: [['expiration', String(now() + 60)]], + } + + modify?.(event) + + try { + const signedEvent = await this.signer.signEvent(event) + const json = JSON.stringify(signedEvent) + return 'Nostr ' + btoa(json) + } catch { + return '' + } } private async httpCall( @@ -28,92 +661,44 @@ export class BlossomClient { url: string, contentType?: string, addAuthorization?: () => Promise, - body?: File | Blob, - result?: any, + body?: File | Blob | string, ): Promise { - const headers: { [_: string]: string } = {} - - if (contentType) { - headers['Content-Type'] = contentType - } + const headers: Record = {} + if (contentType) headers['Content-Type'] = contentType if (addAuthorization) { const auth = await addAuthorization() - if (auth) { - headers['Authorization'] = auth - } + if (auth) headers['Authorization'] = auth } - const response = await fetch(this.mediaserver + url, { - method, - headers, - body, - }) + const res = 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 (res.status >= 300) { + const reason = res.headers.get('X-Reason') || res.statusText + throw new Error(`${url} returned error (${res.status}): ${reason}`) } - if (result !== null && response.headers.get('content-type')?.includes('application/json')) { - return await response.json() + if (res.headers.get('content-type')?.includes('application/json')) { + return res.json() } - return response + return res } - 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 '' - } - } - - async check(hash: string): Promise { - if (!isHex32(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 { + async uploadBlob(file: Blob | File, contentType?: string): Promise { const hash = bytesToHex(sha256(new Uint8Array(await file.arrayBuffer()))) - const actualContentType = contentType || file.type || 'application/octet-stream' + const actualContentType = contentType || getBlobType(file) || 'application/octet-stream' - const bd = await this.httpCall( + return this.httpCall( 'PUT', 'upload', actualContentType, () => this.authorizationHeader(evt => { - evt.tags.push(['t', 'upload']) - evt.tags.push(['x', hash]) + evt.tags.push(['t', 'upload'], ['x', hash]) }), file, - {}, ) - - return bd } async uploadFile(file: File): Promise { @@ -121,32 +706,26 @@ export class BlossomClient { } async download(hash: string): Promise { - if (!isHex32(hash)) { - throw new Error(`${hash} is not a valid 32-byte hex string`) - } + if (!isHex32(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]) + evt.tags.push(['t', 'get'], ['x', hash]) }) - const response = await fetch(this.mediaserver + hash, { + const res = await fetch(this.mediaserver + hash, { method: 'GET', - headers: { - Authorization: authHeader, - }, + headers: { Authorization: authHeader }, }) - if (response.status >= 300) { - throw new Error(`${hash} is not present in ${this.mediaserver}: ${response.status}`) + if (res.status >= 300) { + throw new Error(`${hash} not present on ${this.mediaserver}: ${res.status}`) } - return await response.arrayBuffer() + return res.arrayBuffer() } async downloadAsBlob(hash: string): Promise { - const arrayBuffer = await this.download(hash) - return new Blob([arrayBuffer]) + return new Blob([await this.download(hash)]) } async list(): Promise { @@ -156,44 +735,39 @@ export class BlossomClient { 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}`) - } + return this.httpCall('GET', `list/${pubkey}`, undefined, () => + this.authorizationHeader(evt => { + evt.tags.push(['t', 'list']) + }), + ) } async delete(hash: string): Promise { - if (!isHex32(hash)) { - throw new Error(`${hash} is not a valid 32-byte hex string`) - } + if (!isHex32(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}`) - } + await this.httpCall('DELETE', hash, undefined, () => + this.authorizationHeader(evt => { + evt.tags.push(['t', 'delete'], ['x', hash]) + }), + ) + } + + async check(hash: string): Promise { + if (!isHex32(hash)) throw new Error(`${hash} is not valid 32-byte hex`) + await this.httpCall('HEAD', hash) + } + + async mirror(remoteBlobURL: string): Promise { + const hash = remoteBlobURL.split('/').pop()?.split('.')[0] || '' + return this.httpCall( + 'PUT', + 'mirror', + 'application/json', + () => + this.authorizationHeader(evt => { + evt.tags.push(['t', 'upload'], ['x', hash]) + }), + JSON.stringify({ url: remoteBlobURL }), + ) } } diff --git a/package.json b/package.json index 3afe344..ee4a9fe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "nostr-tools", - "version": "2.24.3", + "version": "2.25.0", "description": "Tools for making a Nostr client.", "repository": { "type": "git", @@ -270,12 +270,6 @@ "require": "./lib/cjs/nip99.js", "types": "./lib/types/nip99.d.ts" }, - "./nipb0": { - "source": "./nipb0.ts", - "import": "./lib/esm/nipb0.js", - "require": "./lib/cjs/nipb0.js", - "types": "./lib/types/nipb0.d.ts" - }, "./nipb7": { "source": "./nipb7.ts", "import": "./lib/esm/nipb7.js",