From d13ea745ffe1f53589d4837660a34acd934fee6a Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Fri, 6 Jun 2025 13:52:53 -0700 Subject: [PATCH] Add blossom support --- docs/lib/tools.md | 8 +- packages/editor/package.json | 2 +- packages/editor/src/extensions/Welshman.ts | 25 +-- packages/editor/src/index.ts | 2 +- packages/lib/src/Tools.ts | 32 ++- packages/util/package.json | 3 +- packages/util/src/Blossom.ts | 227 +++++++++++++++++++++ packages/util/src/Kinds.ts | 28 ++- packages/util/src/index.ts | 1 + pnpm-lock.yaml | 13 +- 10 files changed, 304 insertions(+), 37 deletions(-) create mode 100644 packages/util/src/Blossom.ts diff --git a/docs/lib/tools.md b/docs/lib/tools.md index e629222..b2d4fea 100644 --- a/docs/lib/tools.md +++ b/docs/lib/tools.md @@ -526,6 +526,12 @@ export declare const hexToBech32: (prefix: string, hex: string) => `${Lowercase< export declare const bech32ToHex: (b32: string) => string; // Converts an array buffer to hex format -export declare const bufferToHex: (buffer: ArrayBuffer) => string; +export declare const bytesToHex: (buffer: ArrayBuffer) => string; + +// Converts a hex string to a Uint8Array buffer +export declare const hexToBytes: (hex: string) => Uint8Array; + +// Computes SHA-256 hash of binary data +export declare const sha256: (data: ArrayBuffer | Uint8Array) => Promise; ``` diff --git a/packages/editor/package.json b/packages/editor/package.json index 4b85cf5..29430b7 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -41,7 +41,7 @@ "@tiptap/suggestion": "^2.11.5", "@welshman/lib": "workspace:*", "@welshman/util": "workspace:*", - "nostr-editor": "github:cesardeazevedo/nostr-editor#98f579b93bd2b5a3344df42f769d2ea4c5d8cf55", + "nostr-editor": "github:cesardeazevedo/nostr-editor#82f37ab", "nostr-tools": "^2.14.2", "tippy.js": "^6.3.7" }, diff --git a/packages/editor/src/extensions/Welshman.ts b/packages/editor/src/extensions/Welshman.ts index 5233b6b..a59f898 100644 --- a/packages/editor/src/extensions/Welshman.ts +++ b/packages/editor/src/extensions/Welshman.ts @@ -15,11 +15,13 @@ import {Text} from "@tiptap/extension-text" import {Placeholder} from "@tiptap/extension-placeholder" import type {PlaceholderOptions} from "@tiptap/extension-placeholder" import type { + UploadTask, NostrOptions, FileUploadOptions, ImageOptions, LinkOptions, NSecRejectOptions, + FileAttributes, } from "nostr-editor" import { NostrExtension, @@ -55,7 +57,10 @@ export type WelshmanExtensionOptions = { codeBlock?: ChildExtensionOptions document?: false dropcursor?: ChildExtensionOptions - fileUpload?: ChildExtensionOptions + fileUpload?: { + extend?: Partial + config?: Partial & Pick + } gapcursor?: false history?: ChildExtensionOptions image?: ChildExtensionOptions @@ -74,9 +79,6 @@ export type WelshmanExtensionOptions = { export interface WelshmanOptions extends NostrOptions { submit?: () => void - sign?: (event: StampedEvent) => Promise - defaultUploadUrl?: string - defaultUploadType?: "nip96" | "blossom" extensions?: WelshmanExtensionOptions } @@ -87,14 +89,8 @@ export const WelshmanExtension = NostrExtension.extend({ }, addExtensions() { - const { - sign, - submit, - defaultUploadUrl = "https://nostr.build", - defaultUploadType = "nip96", - } = this.options + const {submit} = this.options - if (!sign) throw new Error("sign is a required argument to WelshmanExtension") if (!submit) throw new Error("submit is a required argument to WelshmanExtension") const extensionOptions = deepMergeLeft(this.options.extensions || {}, { @@ -121,8 +117,6 @@ export const WelshmanExtension = NostrExtension.extend({ config: { inline: true, group: "inline", - defaultUploadUrl, - defaultUploadType, }, extend: { addNodeView: () => MediaNodeView, @@ -132,8 +126,6 @@ export const WelshmanExtension = NostrExtension.extend({ config: { inline: true, group: "inline", - defaultUploadUrl, - defaultUploadType, }, extend: { addNodeView: () => MediaNodeView, @@ -169,7 +161,6 @@ export const WelshmanExtension = NostrExtension.extend({ }, fileUpload: { config: { - sign, immediateUpload: true, allowedMimeTypes: [ "image/jpeg", @@ -186,7 +177,7 @@ export const WelshmanExtension = NostrExtension.extend({ const extensions: Extensions = [] - const addExtension = (extension: AnyExtension, options?: ChildExtensionOptions | false) => { + const addExtension = (extension: AnyExtension, options?: any) => { if (options === false) return if (options?.extend) { diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 9a9d27e..01cad3f 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -2,4 +2,4 @@ export * from "./nodeviews/index.js" export * from "./extensions/index.js" export * from "./plugins/index.js" export {Editor, NodeViewProps} from "@tiptap/core" -export {UploadTask, BlossomOptions, uploadBlossom, encryptFile, decryptFile} from "nostr-editor" +export {UploadTask, FileAttributes} from "nostr-editor" diff --git a/packages/lib/src/Tools.ts b/packages/lib/src/Tools.ts index e269f74..64bef8e 100644 --- a/packages/lib/src/Tools.ts +++ b/packages/lib/src/Tools.ts @@ -1563,11 +1563,35 @@ export const bech32ToHex = (b32: string) => utf8.encode(bech32.fromWords(bech32.decode(b32 as any, false).words)) /** - * Converts an array buffer to hex format - * @param buffer - ArrayBuffer string to convert + * Converts an array buffer or Uint8Array to hex format + * @param buffer - ArrayBuffer or Uint8Array to convert * @returns Hex encoded string */ -export const bufferToHex = (buffer: ArrayBuffer) => - Array.from(new Uint8Array(buffer)) +export const bytesToHex = (buffer: ArrayBuffer | Uint8Array) => { + if (buffer instanceof ArrayBuffer) { + buffer = new Uint8Array(buffer) + } + + return Array.from(buffer) .map(b => b.toString(16).padStart(2, "0")) .join("") +} + +/** + * Converts a hex string to an array buffer + * @param hex - hex string + * @returns ArrayBuffer + */ +export const hexToBytes = (hex: string) => + new Uint8Array(hex.match(/.{2}/g)!.map(hex => parseInt(hex, 16))) + +/** + * Computes SHA-256 hash of binary data + * @param data - Binary data to hash + * @returns Promise resolving to hex-encoded hash string + */ +export const sha256 = async (data: ArrayBuffer | Uint8Array): Promise => { + const hashBuffer = await crypto.subtle.digest("SHA-256", data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, "0")).join("") +} diff --git a/packages/util/package.json b/packages/util/package.json index 35c4d68..dc11e28 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -20,8 +20,9 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "@welshman/lib": "workspace:*", "@types/ws": "^8.5.13", + "@welshman/lib": "workspace:*", + "js-base64": "^3.7.7", "nostr-tools": "^2.14.2", "nostr-wasm": "^0.1.0" }, diff --git a/packages/util/src/Blossom.ts b/packages/util/src/Blossom.ts new file mode 100644 index 0000000..ed6fd68 --- /dev/null +++ b/packages/util/src/Blossom.ts @@ -0,0 +1,227 @@ +import {Base64} from "js-base64" +import {now, bytesToHex, hexToBytes} from "@welshman/lib" +import {BLOSSOM_AUTH} from "./Kinds.js" +import {makeEvent, SignedEvent} from "./Events.js" + +export type BlossomAuthAction = "get" | "upload" | "list" | "delete" + +export type BlossomAuthEventOpts = { + action: BlossomAuthAction + server: string + hashes?: string[] + expiration?: number + content?: string +} + +export type BlossomServer = { + url: string + pubkey?: string +} + +export type BlossomErrorResponse = { + message: string + reason?: string +} + +export const makeBlossomAuthEvent = ({ + action, + server, + hashes = [], + expiration = now() + 60, + content = `Authorization for ${action} at ${server}`, +}: BlossomAuthEventOpts) => { + const tags: string[][] = [ + ["t", action], + ["expiration", expiration.toString()], + ] + + if (server) { + tags.push(["u", server]) + } + + if (hashes) { + for (const hash of hashes) { + tags.push(["x", hash]) + } + } + + return makeEvent(BLOSSOM_AUTH, {content, tags}) +} + +export const createAuthorizationHeader = (event: SignedEvent): string => { + return `Nostr ${Base64.encode(JSON.stringify(event))}` +} + +export const buildBlobUrl = (server: string, sha256: string, extension?: string): string => { + const url = new URL(server) + const filename = extension ? `${sha256}.${extension}` : sha256 + return `${url.origin}/${filename}` +} + +export const checkBlobExists = async ( + server: string, + sha256: string, + options: { + authEvent?: SignedEvent + } = {}, +): Promise<{exists: boolean; size?: number}> => { + const url = buildBlobUrl(server, sha256) + const headers: Record = {} + + if (options.authEvent) { + headers.Authorization = createAuthorizationHeader(options.authEvent) + } + + try { + const response = await fetch(url, {method: "HEAD", headers}) + + if (response.status === 200) { + const contentLength = response.headers.get("content-length") + return { + exists: true, + size: contentLength ? parseInt(contentLength, 10) : undefined, + } + } + + return {exists: false} + } catch (error) { + throw new Error(`Failed to check blob existence: ${error}`) + } +} + +export const getBlob = async ( + server: string, + sha256: string, + options: { + authEvent?: SignedEvent + range?: {start: number; end?: number} + } = {}, +) => { + const url = buildBlobUrl(server, sha256) + const headers: Record = {} + + if (options.authEvent) { + headers.Authorization = createAuthorizationHeader(options.authEvent) + } + + if (options.range) { + const {end, start} = options.range + + headers.Range = end !== undefined ? `bytes=${start}-${end}` : `bytes=${start}-` + } + + return fetch(url, {headers}) +} + +export const uploadBlob = async ( + server: string, + blob: Blob | ArrayBuffer, + options: { + authEvent?: SignedEvent + } = {}, +) => { + const url = new URL(server) + const uploadUrl = `${url.origin}/upload` + const body = blob instanceof Blob ? blob : new Blob([blob]) + const headers: Record = {} + + if (options.authEvent) { + headers.Authorization = createAuthorizationHeader(options.authEvent) + } + + return fetch(uploadUrl, {method: "PUT", headers, body}) +} + +export const deleteBlob = async ( + server: string, + sha256: string, + options: { + authEvent?: SignedEvent + } = {}, +) => { + const url = buildBlobUrl(server, sha256) + + const headers: Record = {} + + if (options.authEvent) { + headers.Authorization = createAuthorizationHeader(options.authEvent) + } + + return fetch(url, {method: "DELETE", headers}) +} + +export const listBlobs = async ( + server: string, + pubkey: string, + options: { + authEvent?: SignedEvent + since?: number + until?: number + } = {}, +) => { + const url = new URL(server) + const listUrl = `${url.origin}/list/${pubkey}` + + const searchParams = new URLSearchParams() + if (options.since !== undefined) { + searchParams.append("since", options.since.toString()) + } + if (options.until !== undefined) { + searchParams.append("until", options.until.toString()) + } + + const fullUrl = searchParams.toString() ? `${listUrl}?${searchParams.toString()}` : listUrl + + const headers: Record = {} + + if (options.authEvent) { + headers.Authorization = createAuthorizationHeader(options.authEvent) + } + + return fetch(fullUrl, {headers}) +} + +export interface EncryptedFile { + key: string + nonce: string + ciphertext: Uint8Array + algorithm: string +} + +export async function encryptFile(file: Blob): Promise { + const key = await crypto.subtle.generateKey({name: "AES-GCM", length: 256}, true, [ + "encrypt", + "decrypt", + ]) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const fileBuffer = await file.arrayBuffer() + const ciphertext = await crypto.subtle.encrypt({name: "AES-GCM", iv}, key, fileBuffer) + const keyBytes = await crypto.subtle.exportKey("raw", key) + + return { + ciphertext: new Uint8Array(ciphertext), + key: bytesToHex(keyBytes), + nonce: bytesToHex(iv), + algorithm: "aes-gcm", + } +} + +export async function decryptFile({ + key, + nonce, + ciphertext, + algorithm, +}: EncryptedFile): Promise { + if (algorithm !== "aes-gcm") { + throw new Error(`Unknown algorithm ${algorithm}`) + } + + const keyBytes = hexToBytes(key) + const iv = hexToBytes(nonce) + const cryptoKey = await crypto.subtle.importKey("raw", keyBytes, {name: "AES-GCM"}, false, [ + "decrypt", + ]) + const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, cryptoKey, ciphertext) + + return new Uint8Array(decryptedBuffer) +} diff --git a/packages/util/src/Kinds.ts b/packages/util/src/Kinds.ts index a0b7e71..6748235 100644 --- a/packages/util/src/Kinds.ts +++ b/packages/util/src/Kinds.ts @@ -1,12 +1,26 @@ -import { - isRegularKind, - isEphemeralKind, - isReplaceableKind as isPlainReplaceableKind, - isParameterizedReplaceableKind, -} from "nostr-tools/kinds" import {between} from "@welshman/lib" -export {isRegularKind, isEphemeralKind, isPlainReplaceableKind, isParameterizedReplaceableKind} +/** Events are **regular**, which means they're all expected to be stored by relays. */ +export function isRegularKind(kind: number): boolean { + return ( + (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind) + ) +} + +/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */ +export function isPlainReplaceableKind(kind: number): boolean { + return [0, 3].includes(kind) || (10000 <= kind && kind < 20000) +} + +/** Events are **ephemeral**, which means they are not expected to be stored by relays. */ +export function isEphemeralKind(kind: number): boolean { + return 20000 <= kind && kind < 30000 +} + +/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */ +export function isParameterizedReplaceableKind(kind: number): boolean { + return 30000 <= kind && kind < 40000 +} export const isReplaceableKind = (kind: number) => isPlainReplaceableKind(kind) || isParameterizedReplaceableKind(kind) diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 1f1ed02..5d0752a 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -1,4 +1,5 @@ export * from "./Address.js" +export * from "./Blossom.js" export * from "./Encryptable.js" export * from "./Events.js" export * from "./Filters.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5e23fc..50eeb93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,8 +204,8 @@ importers: specifier: workspace:* version: link:../util nostr-editor: - specifier: github:cesardeazevedo/nostr-editor#98f579b93bd2b5a3344df42f769d2ea4c5d8cf55 - version: https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/98f579b93bd2b5a3344df42f769d2ea4c5d8cf55(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-image@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)))(@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(linkifyjs@4.2.0)(nostr-tools@2.14.2(typescript@5.8.2))(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(tiptap-markdown@0.8.10(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))) + specifier: github:cesardeazevedo/nostr-editor#82f37ab + version: https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/82f37ab(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-image@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)))(@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(linkifyjs@4.2.0)(nostr-tools@2.14.2(typescript@5.8.2))(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(tiptap-markdown@0.8.10(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))) nostr-tools: specifier: ^2.14.2 version: 2.14.2(typescript@5.8.2) @@ -397,6 +397,9 @@ importers: '@welshman/lib': specifier: workspace:* version: link:../lib + js-base64: + specifier: ^3.7.7 + version: 3.7.7 nostr-tools: specifier: ^2.14.2 version: 2.14.2(typescript@5.8.2) @@ -2023,8 +2026,8 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - nostr-editor@https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/98f579b93bd2b5a3344df42f769d2ea4c5d8cf55: - resolution: {tarball: https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/98f579b93bd2b5a3344df42f769d2ea4c5d8cf55} + nostr-editor@https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/82f37ab: + resolution: {tarball: https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/82f37ab} version: 0.0.4-pre.17 engines: {node: '>=18.16.1'} peerDependencies: @@ -4210,7 +4213,7 @@ snapshots: normalize-path@3.0.0: {} - nostr-editor@https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/98f579b93bd2b5a3344df42f769d2ea4c5d8cf55(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-image@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)))(@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(linkifyjs@4.2.0)(nostr-tools@2.14.2(typescript@5.8.2))(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(tiptap-markdown@0.8.10(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))): + nostr-editor@https://codeload.github.com/cesardeazevedo/nostr-editor/tar.gz/82f37ab(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-image@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7)))(@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(linkifyjs@4.2.0)(nostr-tools@2.14.2(typescript@5.8.2))(prosemirror-markdown@1.13.2)(prosemirror-model@1.25.0)(prosemirror-state@1.4.3)(tiptap-markdown@0.8.10(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))): dependencies: '@tiptap/core': 2.11.7(@tiptap/pm@2.11.7) '@tiptap/extension-image': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))