Let the blossom server decide which files can be attached

This commit is contained in:
Coracle-Bot 2026-09-08 21:45:44 +00:00 committed by hodlbod
parent 1da59fb322
commit 78fe1e11f4
3 changed files with 121 additions and 38 deletions

View file

@ -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
}

View file

@ -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))

View file

@ -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<string, string> = {
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()