From 78fe1e11f47f8c56e002d2c7bafc487f4ef42977 Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Tue, 8 Sep 2026 21:45:44 +0000 Subject: [PATCH] Let the blossom server decide which files can be attached --- src/app/editor/index.ts | 85 ++++++++++++++++++++++++++++++++++------- src/app/share.ts | 5 --- src/app/uploads.ts | 69 ++++++++++++++++++++++++--------- 3 files changed, 121 insertions(+), 38 deletions(-) diff --git a/src/app/editor/index.ts b/src/app/editor/index.ts index 3c2ebdf4..c12d5661 100644 --- a/src/app/editor/index.ts +++ b/src/app/editor/index.ts @@ -31,7 +31,7 @@ import ProfileSuggestion from "@app/editor/ProfileSuggestion.svelte" import {RoomReferenceExtension} from "@app/editor/RoomReferenceExtension" import RoomSuggestion from "@app/editor/RoomSuggestion.svelte" import {NativeClipboardPasteExtension} from "@app/editor/clipboard" -import {UPLOAD_MIME_TYPES, compressFileForUpload, uploadFile} from "@app/uploads" +import {compressFileForUpload, makeImetaTag, uploadFile} from "@app/uploads" import {userSpaceUrls} from "@app/rooms" import {PLATFORM_RELAYS} from "@app/env" import {pushToast} from "@app/toast" @@ -132,6 +132,39 @@ export const makeEditor = async ({ } } + // A file the editor has no node for is uploaded here instead, so its imeta has to join what + // the nostr extension collects off the document, and it has to hold `uploading` open itself. + const attachments: {url: string; tag: string[]}[] = [] + + let attaching = 0 + + const setUploading = () => + uploading?.set( + attaching > 0 || + ed.storage.fileUpload.uploader.getFiles().some((attrs: FileAttributes) => attrs.uploading), + ) + + const attachFile = async (editor: Editor, file: File) => { + attaching += 1 + uploading?.set(true) + + const {error, result} = await uploadFile(file, {url, encrypt: encryptFiles}) + + attaching -= 1 + setUploading() + + if (result) { + attachments.push({url: result.url, tag: makeImetaTag(file, result)}) + editor + .chain() + .focus() + .insertContent({type: "text", text: result.url + " "}) + .run() + } else { + pushToast({theme: "error", message: error}) + } + } + const ed = new Editor({ content: typeof content === "string" ? escapeHtml(content) : content, editorProps, @@ -154,36 +187,47 @@ export const makeEditor = async ({ }, fileUpload: { config: { - allowedMimeTypes: UPLOAD_MIME_TYPES, + allowedMimeTypes: ["*/*"], upload: async (attrs: FileAttributes) => uploadFile(await compressFileForUpload(attrs.file), {url, encrypt: encryptFiles}), onDrop: () => uploading?.set(true), - onComplete: () => uploading?.set(false), + onComplete: () => setUploading(), onUploadError(currentEditor, task) { currentEditor.commands.removeFailedUploads() pushToast({theme: "error", message: task.error}) - uploading?.set(false) + setUploading() }, }, extend: { // The picker, a drop and a paste all reach the uploader through addFile, which - // refuses a type that isn't allowed above by returning false and saying nothing. - // Say it, or choosing the wrong file looks like the app simply ignored the click. + // only knows how to make an image or a video node. Everything else is uploaded on + // its own, so what you can attach is the server's call rather than a list here. onCreate() { const {uploader} = this.storage const addFile = uploader.addFile.bind(uploader) uploader.addFile = (file: File, pos: number) => { - const added = addFile(file, pos) - - if (!added) { - pushToast({ - theme: "error", - message: `${file.name} is not a type you can attach.`, - }) + if (file.type.match(/^(image|video)\//)) { + return addFile(file, pos) } - return added + attachFile(this.editor, file) + + return true + } + + // Paste filters by mime type before it ever reaches addFile. + uploader.handlePaste = (event: ClipboardEvent) => { + const files = Array.from(event.clipboardData?.items || []) + .filter(item => item.kind === "file") + .map(item => item.getAsFile()) + .filter(Boolean) as File[] + + for (const file of files) { + uploader.addFile(file, uploader.view.state.selection.from + 1) + } + + return files.length > 0 } }, }, @@ -268,5 +312,18 @@ export const makeEditor = async ({ empty?.set(isEmpty(ed)) text?.set(ed.getText({blockSeparator: "\n"})) + // Deleting the url an attachment was inserted as is how you remove it, since there is no node + // to delete. + const getEditorTags = ed.storage.nostr.getEditorTags + + ed.storage.nostr.getEditorTags = (...args: unknown[]) => { + const content = ed.getText({blockSeparator: "\n"}) + + return [ + ...getEditorTags(...args), + ...attachments.filter(({url}) => content.includes(url)).map(({tag}) => tag), + ] + } + return ed } diff --git a/src/app/share.ts b/src/app/share.ts index 493a40e5..35cbd4ba 100644 --- a/src/app/share.ts +++ b/src/app/share.ts @@ -8,7 +8,6 @@ import {app, relays} from "@app/core" import {navigate, pushModal} from "@app/modal" import {makeSpaceChatPath} from "@app/routes" import {pushToast} from "@app/toast" -import {UPLOAD_MIME_TYPES} from "@app/uploads" import ShareDialog from "@app/components/Share.svelte" import ShareEvent from "@app/components/ShareEvent.svelte" @@ -64,10 +63,6 @@ export const shareFromNative = async ({ if (text) { shareText(text) } else if (path) { - if (!UPLOAD_MIME_TYPES.includes(mimeType)) { - return pushToast({theme: "error", message: "Flotilla can't share that type of file."}) - } - try { const response = await fetch(Capacitor.convertFileSrc(path)) diff --git a/src/app/uploads.ts b/src/app/uploads.ts index fca5d923..bc7ef643 100644 --- a/src/app/uploads.ts +++ b/src/app/uploads.ts @@ -2,7 +2,6 @@ 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 type {UploadTask} from "@welshman/editor" import {compressFile} from "@lib/html" import {app, blossomServerLists, relays} from "@app/core" import {DEFAULT_BLOSSOM_SERVERS} from "@app/env" @@ -62,22 +61,6 @@ export const getBlossomServer = async (options: GetBlossomServerOptions = {}) => return first(DEFAULT_BLOSSOM_SERVERS)! } -// The editor's default list leaves out the formats an iPhone camera actually produces, so -// anything shared from Photos would be rejected without a word. Heic gets re-encoded by the -// compressor on its way to a blossom server; quicktime is uploaded as-is. -export const UPLOAD_MIME_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/heic", - "image/heif", - "video/mp4", - "video/mpeg", - "video/webm", - "video/quicktime", -] - export type CompressFileOptions = { maxWidth?: number maxHeight?: number @@ -94,9 +77,38 @@ export type UploadFileOptions = { encrypt?: boolean } +export type UploadResult = { + url: string + sha256: string + tags: string[][] +} + export type UploadFileResult = { error?: string - result?: UploadTask + 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 = {}) => { @@ -119,12 +131,31 @@ export const uploadFile = async (file: File, options: UploadFileOptions = {}) => }) } - const ext = "." + type.split("/")[1] + // 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()