mirror of
https://github.com/coracle-social/flotilla.git
synced 2025-12-10 10:57:04 +00:00
396 lines
10 KiB
TypeScript
396 lines
10 KiB
TypeScript
import type {CommandProps, Editor} from "@tiptap/core"
|
|
import {Extension} from "@tiptap/core"
|
|
import {now} from "@welshman/lib"
|
|
import type {StampedEvent, SignedEvent} from "@welshman/util"
|
|
import type {ImageAttributes, VideoAttributes} from "nostr-editor"
|
|
import {readServerConfig, uploadFile} from "nostr-tools/nip96"
|
|
import {getToken} from "nostr-tools/nip98"
|
|
import type {Node} from "prosemirror-model"
|
|
import {Plugin, PluginKey} from "prosemirror-state"
|
|
import {writable} from "svelte/store"
|
|
|
|
declare module "@tiptap/core" {
|
|
interface Commands<ReturnType> {
|
|
uploadFile: {
|
|
selectFiles: () => ReturnType
|
|
uploadFiles: () => ReturnType
|
|
getMetaTags: () => string[][]
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface FileUploadOptions {
|
|
allowedMimeTypes: string[]
|
|
expiration: number
|
|
immediateUpload: boolean
|
|
hash: (file: File) => Promise<string>
|
|
sign?: (event: StampedEvent) => Promise<SignedEvent | undefined>
|
|
onDrop: (currentEditor: Editor, file: File, pos: number) => void
|
|
onComplete: (currentEditor: Editor) => void
|
|
}
|
|
|
|
interface UploadTask {
|
|
url?: string
|
|
sha256?: string
|
|
error?: string
|
|
}
|
|
|
|
function bufferToHex(buffer: ArrayBuffer) {
|
|
return Array.from(new Uint8Array(buffer))
|
|
.map(b => b.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
}
|
|
|
|
export const FileUploadExtension = Extension.create<FileUploadOptions>({
|
|
name: "fileUpload",
|
|
|
|
addStorage() {
|
|
return {
|
|
loading: writable(false),
|
|
tags: [] as string[][],
|
|
}
|
|
},
|
|
|
|
addOptions() {
|
|
return {
|
|
allowedMimeTypes: [
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"video/mp4",
|
|
"video/mpeg",
|
|
"video/webm",
|
|
],
|
|
immediateUpload: true,
|
|
expiration: 60000,
|
|
async hash(file: File) {
|
|
return bufferToHex(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))
|
|
},
|
|
onDrop() {},
|
|
onComplete() {},
|
|
}
|
|
},
|
|
|
|
addCommands() {
|
|
return {
|
|
selectFiles: () => props => {
|
|
props.tr.setMeta("selectFiles", true)
|
|
return true
|
|
},
|
|
uploadFiles: () => (props: CommandProps) => {
|
|
props.tr.setMeta("uploadFiles", true)
|
|
return true
|
|
},
|
|
getMetaTags: () =>
|
|
((props: CommandProps) => {
|
|
const tags: string[][] = []
|
|
// make sure the file uploaded is still in the editor content
|
|
props.editor.state.doc.descendants(node => {
|
|
if (!(node.type.name === "image" || node.type.name === "video")) {
|
|
return
|
|
}
|
|
const tag = props.editor.storage.fileUpload.tags.find((t: string[]) =>
|
|
t[1].includes(node.attrs.src),
|
|
)
|
|
if (tag) {
|
|
tags.push(tag)
|
|
}
|
|
})
|
|
return tags
|
|
}) as any,
|
|
}
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
const uploader = new Uploader(this.editor, this.options)
|
|
return [
|
|
new Plugin({
|
|
key: new PluginKey("fileUploadPlugin"),
|
|
state: {
|
|
init() {
|
|
return {}
|
|
},
|
|
apply(tr) {
|
|
setTimeout(() => {
|
|
if (tr.getMeta("selectFiles")) {
|
|
uploader.selectFiles()
|
|
tr.setMeta("selectFiles", null)
|
|
} else if (tr.getMeta("uploadFiles")) {
|
|
uploader.uploadFiles()
|
|
tr.setMeta("uploadFiles", null)
|
|
}
|
|
})
|
|
return {}
|
|
},
|
|
},
|
|
props: {
|
|
handleDrop: (_, event) => {
|
|
return uploader.handleDrop(event)
|
|
},
|
|
},
|
|
}),
|
|
]
|
|
},
|
|
})
|
|
|
|
class Uploader {
|
|
constructor(
|
|
public editor: Editor,
|
|
private options: FileUploadOptions,
|
|
) {}
|
|
|
|
get view() {
|
|
return this.editor.view
|
|
}
|
|
|
|
addFile(file: File, pos: number) {
|
|
if (
|
|
!this.options.allowedMimeTypes.some(amt => amt.split("*").every(s => file.type.includes(s)))
|
|
) {
|
|
return false
|
|
}
|
|
const {tr} = this.view.state
|
|
const [mimetype] = file.type.split("/")
|
|
const node = this.view.state.schema.nodes[mimetype].create({
|
|
file,
|
|
src: URL.createObjectURL(file),
|
|
alt: "",
|
|
uploading: false,
|
|
uploadError: null,
|
|
})
|
|
tr.insert(pos, node)
|
|
this.view.dispatch(tr)
|
|
|
|
if (this.options.immediateUpload) {
|
|
this.editor.storage.fileUpload.loading.set(true)
|
|
this.upload(node).then(() => this.editor.storage.fileUpload.loading.set(false))
|
|
}
|
|
this.options.onDrop(this.editor, file, pos)
|
|
return true
|
|
}
|
|
|
|
findNodePosition(node: Node) {
|
|
let pos = -1
|
|
this.view.state.doc.descendants((n, p) => {
|
|
if (n === node) {
|
|
pos = p
|
|
return false
|
|
}
|
|
})
|
|
return pos
|
|
}
|
|
|
|
findNodes(uploading: boolean) {
|
|
const nodes = [] as [Node, number][]
|
|
this.view.state.doc.descendants((node, pos) => {
|
|
if (!(node.type.name === "image" || node.type.name === "video")) {
|
|
return
|
|
}
|
|
if (node.attrs.sha256) {
|
|
return
|
|
}
|
|
if ((node.attrs.uploading || false) !== uploading) {
|
|
return
|
|
}
|
|
nodes.push([node, pos])
|
|
})
|
|
return nodes
|
|
}
|
|
|
|
updateNodeAttributes(nodeRef: Node, attrs: Record<string, unknown>) {
|
|
const {tr} = this.editor.view.state
|
|
|
|
const pos = this.findNodePosition(nodeRef)
|
|
if (pos === -1) return
|
|
|
|
Object.entries(attrs).forEach(
|
|
([key, value]) => value !== undefined && tr.setNodeAttribute(pos, key, value),
|
|
)
|
|
this.view.dispatch(tr)
|
|
}
|
|
|
|
onUploadDone(nodeRef: Node, response: UploadTask) {
|
|
this.findNodes(true).forEach(([node, pos]) => {
|
|
if (node.attrs.src === nodeRef.attrs.src) {
|
|
this.updateNodeAttributes(node, {
|
|
uploading: false,
|
|
src: response.url,
|
|
sha256: response.sha256,
|
|
uploadError: response.error,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
async upload(node: Node) {
|
|
const {sign, hash, expiration} = this.options
|
|
|
|
const {
|
|
file,
|
|
alt,
|
|
uploadType,
|
|
uploadUrl: serverUrl,
|
|
} = node.attrs as ImageAttributes | VideoAttributes
|
|
|
|
this.updateNodeAttributes(node, {uploading: true, uploadError: null})
|
|
|
|
try {
|
|
if (uploadType === "nip96") {
|
|
const res = (await uploadNIP96({file, alt, sign: sign!, serverUrl}))!
|
|
|
|
// add the tags as received from nip-96 to the storage
|
|
this.editor.storage.fileUpload.tags.push(["imeta", ...res.tags!])
|
|
this.onUploadDone(node, res)
|
|
} else {
|
|
const res = await uploadBlossom({file, serverUrl, hash, sign, expiration})
|
|
this.editor.storage.fileUpload.tags.push([
|
|
"imeta",
|
|
`url ${res.url}`,
|
|
`size ${res.size}`,
|
|
`m ${res.type}`,
|
|
`x ${res.sha256}`,
|
|
])
|
|
this.onUploadDone(node, res)
|
|
}
|
|
} catch (error) {
|
|
const msg = error as string
|
|
this.onUploadDone(node, {error: msg})
|
|
throw new Error(msg as string)
|
|
}
|
|
}
|
|
|
|
async uploadFiles() {
|
|
const tasks = this.findNodes(false).map(([node]) => {
|
|
return this.upload(node)
|
|
})
|
|
try {
|
|
this.editor.storage.fileUpload.loading.set(true)
|
|
await Promise.all(tasks)
|
|
this.options.onComplete(this.editor)
|
|
} finally {
|
|
this.editor.storage.fileUpload.loading.set(false)
|
|
}
|
|
}
|
|
|
|
selectFiles() {
|
|
const input = document.createElement("input")
|
|
input.type = "file"
|
|
input.multiple = true
|
|
input.accept = this.options.allowedMimeTypes.join(",")
|
|
input.onchange = event => {
|
|
const files = (event.target as HTMLInputElement).files
|
|
if (files) {
|
|
Array.from(files).forEach(file => {
|
|
if (file) {
|
|
const pos = this.view.state.selection.from + 1
|
|
this.addFile(file, pos)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
input.click()
|
|
}
|
|
|
|
handleDrop(event: DragEvent) {
|
|
event.preventDefault()
|
|
|
|
const pos = this.view.posAtCoords({left: event.clientX, top: event.clientY})?.pos
|
|
|
|
if (pos === undefined) return false
|
|
|
|
const file = event.dataTransfer?.files?.[0]
|
|
if (file) {
|
|
this.addFile(file, pos)
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface NIP96Options {
|
|
file: File
|
|
alt?: string
|
|
serverUrl: string
|
|
expiration?: number
|
|
sign: (event: StampedEvent) => Promise<SignedEvent | undefined>
|
|
}
|
|
|
|
export async function uploadNIP96(options: NIP96Options) {
|
|
try {
|
|
const server = await readServerConfig(options.serverUrl)
|
|
const authorization = await getToken(server.api_url, "POST", options.sign as any, true)
|
|
const res = await uploadFile(options.file, server.api_url, authorization, {
|
|
alt: options.alt || "",
|
|
expiration: options.expiration?.toString() || "",
|
|
content_type: options.file.type,
|
|
})
|
|
if (res.status === "error") {
|
|
throw new Error(res.message)
|
|
}
|
|
const url = res.nip94_event?.tags.find(x => x[0] === "url")?.[1] || ""
|
|
const sha256 = res.nip94_event?.tags.find(x => x[0] === "x")?.[1] || ""
|
|
return {
|
|
url,
|
|
sha256,
|
|
tags: res.nip94_event?.tags.flatMap(item => item.join(" ")),
|
|
}
|
|
} catch (error) {
|
|
console.warn(error)
|
|
}
|
|
}
|
|
|
|
export interface BlossomOptions {
|
|
file: File
|
|
serverUrl: string
|
|
expiration?: number
|
|
hash?: (file: File) => Promise<string>
|
|
sign?: (event: StampedEvent) => Promise<SignedEvent | undefined>
|
|
}
|
|
|
|
export interface BlossomResponse {
|
|
sha256: string
|
|
size: number
|
|
type: string
|
|
uploaded: number
|
|
url: string
|
|
}
|
|
|
|
export interface BlossomResponseError {
|
|
message: string
|
|
}
|
|
|
|
export async function uploadBlossom(options: BlossomOptions) {
|
|
if (!options.hash) {
|
|
throw new Error("No hash function provided")
|
|
}
|
|
if (!options.sign) {
|
|
throw new Error("No signer provided")
|
|
}
|
|
const created_at = now()
|
|
const hash = await options.hash(options.file)
|
|
const event = await options.sign({
|
|
kind: 24242,
|
|
content: `Upload ${options.file.name}`,
|
|
created_at,
|
|
tags: [
|
|
["t", "upload"],
|
|
["x", hash],
|
|
["size", options.file.size.toString()],
|
|
["expiration", (created_at + (options.expiration || 60000)).toString()],
|
|
],
|
|
})
|
|
const data = JSON.stringify(event)
|
|
const base64 = btoa(data)
|
|
const authorization = `Nostr ${base64}`
|
|
const res = await fetch(options.serverUrl + "/upload", {
|
|
method: "PUT",
|
|
body: options.file,
|
|
headers: {
|
|
authorization,
|
|
},
|
|
})
|
|
const json = await res.json()
|
|
if (res.status === 200) {
|
|
return json as BlossomResponse
|
|
}
|
|
throw new Error((json as BlossomResponseError).message)
|
|
}
|