Add blossom support

This commit is contained in:
Jon Staab
2025-06-06 13:52:53 -07:00
parent bc65b96d46
commit d13ea745ff
10 changed files with 304 additions and 37 deletions
+227
View File
@@ -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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
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<EncryptedFile> {
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<Uint8Array> {
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)
}
+21 -7
View File
@@ -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)
+1
View File
@@ -1,4 +1,5 @@
export * from "./Address.js"
export * from "./Blossom.js"
export * from "./Encryptable.js"
export * from "./Events.js"
export * from "./Filters.js"