import type {Maybe} from "@welshman/lib" import {first, normalizeUrl, parseJson, sha256, simpleCache} from "@welshman/lib" import {canUploadBlob, encryptFile, makeBlossomAuthEvent, uploadBlob} from "@welshman/util" import {Nip01Signer} from "@welshman/signer" import {compressFile} from "@lib/html" import {app, blossomServerLists, relays} from "@app/core" import {DEFAULT_BLOSSOM_SERVERS} from "@app/env" export const normalizeBlossomUrl = (url: string) => normalizeUrl(url.replace(/^ws/, "http")) export const fetchHasBlossomSupport = async (url: string) => { if (relays.get().get(url)?.hasNip("BUD-02")) { return true } const server = normalizeBlossomUrl(url) const $signer = app.get().user?.signer || Nip01Signer.ephemeral() const headers: Record = { "X-Content-Type": "text/plain", "X-Content-Length": "1", "X-SHA-256": "73cb3858a687a8494ca3323053016282f3dad39d42cf62ca4e79dda2aac7d9ac", } try { const authEvent = await $signer.sign(makeBlossomAuthEvent({action: "upload", server})) const res = await canUploadBlob(server, {authEvent, headers}) return res.status === 200 } catch (e) { if (!String(e).match(/Failed to fetch|NetworkError/)) { console.error(e) } } return false } export const hasBlossomSupport = simpleCache(([url]: [string]) => fetchHasBlossomSupport(url)) export type GetBlossomServerOptions = { url?: string } export const getBlossomServer = async (options: GetBlossomServerOptions = {}) => { if (options.url) { if (await hasBlossomSupport(options.url)) { return normalizeBlossomUrl(options.url) } } const $pubkey = app.get().user?.pubkey if ($pubkey) { const userUrl = first(blossomServerLists.get().urls($pubkey).get()) if (userUrl) { return normalizeBlossomUrl(userUrl) } } return first(DEFAULT_BLOSSOM_SERVERS)! } export type CompressFileOptions = { maxWidth?: number maxHeight?: number quality?: number mimeType?: string } // Webp/gif/svg either break the compressor or lose animation, pass them through untouched export const compressFileForUpload = async (file: File, options: CompressFileOptions = {}) => file.type.match("image/(webp|gif|svg)") ? file : compressFile(file, options) export type UploadFileOptions = { url?: string encrypt?: boolean } export type UploadResult = { url: string sha256: string tags: string[][] } export type UploadFileResult = { error?: string result?: UploadResult } // Mirrors the imeta the editor builds for the media it holds as a node, for the files it // doesn't. export const makeImetaTag = (file: File, result: UploadResult) => { const meta: Record = { url: result.url, x: result.sha256, ox: result.sha256, m: file.type, size: String(file.size), } for (const [k, v] of result.tags) { meta[k] = v } return [ "imeta", ...Object.entries(meta) .map(entry => entry.join(" ")) .sort(), ] } export const uploadFile = async (file: File, options: UploadFileOptions = {}) => { try { const {name, type} = file const tags: string[][] = [] if (options.encrypt) { const {ciphertext, key, nonce, algorithm} = await encryptFile(file) tags.push( ["decryption-key", key], ["decryption-nonce", nonce], ["encryption-algorithm", algorithm], ) file = new File([new Uint8Array(ciphertext)], name, { type: "application/octet-stream", }) } // A subtype worth putting on a url is a plain word. Anything else (an office document, a // file the platform gave no type at all) keeps whatever the server named it. const [, subtype = ""] = type.split("/") const ext = /^[a-z0-9]+$/.test(subtype) ? "." + subtype : "" const server = await getBlossomServer(options) const hashes = [await sha256(await file.arrayBuffer())] const $signer = app.get().user?.signer || Nip01Signer.ephemeral() const authTemplate = makeBlossomAuthEvent({action: "upload", server, hashes}) const authEvent = await $signer.sign(authTemplate) // What a server takes is its own business, so ask before spending the upload. A 404 or a // 405 is a server that doesn't implement BUD-06 rather than one saying no. const check = await canUploadBlob(server, { authEvent, headers: { "X-Content-Type": file.type, "X-Content-Length": String(file.size), "X-SHA-256": hashes[0], }, }) if (![200, 404, 405].includes(check.status)) { return {error: check.headers.get("X-Reason") || `${name} was refused (HTTP ${check.status})`} } const res = await uploadBlob(server, file, {authEvent}) const text = await res.text() let task try { task = parseJson(text) } catch (e) { return {error: text} } if (!task?.uploaded) { return {error: text || `Failed to upload file (HTTP ${res.status})`} } // Always append correct file extension if we encrypted the file, or if it's missing let url = task.url if (options.encrypt) { url = url.replace(/\.\w+$/, "") + ext } else if (new URL(url).pathname.split(".").length === 1) { url += ext } const result = {...task, tags, url} return {result} } catch (e: any) { console.error("Error caught when uploading file:", e) return {error: e.toString()} } } const readFileAsDataUrl = (file: File) => new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener("load", () => resolve(reader.result as string)) reader.addEventListener("error", () => reject(reader.error)) reader.readAsDataURL(file) }) export const uploadFileOrFallback = async ( file: File, options: UploadFileOptions = {}, ): Promise<{url: string; tags: string[][]}> => { const {result} = await uploadFile(file, options) if (result?.url) { return result } return {url: await readFileAsDataUrl(file), tags: []} } export const resolveImageInput = async ( file: Maybe, url: Maybe, options: CompressFileOptions = {}, ) => { if (!file) { return url } const compressedFile = await compressFileForUpload(file, options) const result = await uploadFileOrFallback(compressedFile) return result.url }