From 28aebc9fbf96ae6267dc0ad4bfb9e7669c9767c7 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 30 Jan 2025 20:24:07 -0500 Subject: [PATCH] feat: Upload remote-hosted images on paste (#8301) * First pass * fix * tidy, tidy * Determine dimensions * docs * test getFileNameFromUrl * PR feedback * tsc --- app/components/Editor.tsx | 12 ++- app/utils/files.ts | 31 +++++++ .../server/tasks/DeliverWebhookTask.ts | 1 + .../tasks/UploadAttachmentFromUrlTask.ts | 51 +++++++++++ server/routes/api/attachments/attachments.ts | 81 ++++++++++++++++- server/routes/api/attachments/schema.ts | 19 ++++ server/storage/files/BaseStorage.ts | 9 +- server/types.ts | 4 + shared/editor/commands/createAndInsertLink.ts | 88 ------------------- shared/editor/commands/insertFiles.ts | 2 +- shared/editor/extensions/Mermaid.ts | 3 +- shared/editor/lib/FileHelper.ts | 17 ++++ shared/editor/plugins/UploadPlugin.ts | 40 +++++++++ shared/editor/queries/findChildren.ts | 6 +- shared/editor/queries/findCollapsedNodes.ts | 3 +- shared/editor/types/index.ts | 5 ++ shared/utils/files.test.ts | 21 ++++- shared/utils/files.ts | 17 ++++ 18 files changed, 304 insertions(+), 106 deletions(-) create mode 100644 server/queues/tasks/UploadAttachmentFromUrlTask.ts delete mode 100644 shared/editor/commands/createAndInsertLink.ts diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 6e7151fe28..4080fe4497 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -17,7 +17,7 @@ import useDictionary from "~/hooks/useDictionary"; import useEditorClickHandlers from "~/hooks/useEditorClickHandlers"; import useEmbeds from "~/hooks/useEmbeds"; import useStores from "~/hooks/useStores"; -import { uploadFile } from "~/utils/files"; +import { uploadFile, uploadFileFromUrl } from "~/utils/files"; import lazyWithRetry from "~/utils/lazyWithRetry"; const LazyLoadedEditor = lazyWithRetry(() => import("~/editor")); @@ -49,11 +49,15 @@ function Editor(props: Props, ref: React.RefObject | null) { const previousCommentIds = React.useRef(); const handleUploadFile = React.useCallback( - async (file: File) => { - const result = await uploadFile(file, { + async (file: File | string) => { + const options = { documentId: id, preset: AttachmentPreset.DocumentAttachment, - }); + }; + const result = + file instanceof File + ? await uploadFile(file, options) + : await uploadFileFromUrl(file, options); return result.url; }, [id] diff --git a/app/utils/files.ts b/app/utils/files.ts index 6607a6b12d..16b66a2c43 100644 --- a/app/utils/files.ts +++ b/app/utils/files.ts @@ -14,6 +14,31 @@ type UploadOptions = { onProgress?: (fractionComplete: number) => void; }; +/** + * Upload a file from a URL + * + * @param url The remote URL to download the file from + * @param options The upload options + * @returns The attachment object + */ +export const uploadFileFromUrl = async ( + url: string, + options: UploadOptions +) => { + const response = await client.post("/attachments.createFromUrl", { + documentId: options.documentId, + url, + }); + return response.data; +}; + +/** + * Upload a file + * + * @param file The file to upload + * @param options The upload options + * @returns The attachment object + */ export const uploadFile = async ( file: File | Blob, options: UploadOptions = { @@ -74,6 +99,12 @@ export const uploadFile = async ( return attachment; }; +/** + * Convert a data URL to a Blob + * + * @param dataURL The data URL to convert + * @returns The Blob + */ export const dataUrlToBlob = (dataURL: string) => { const blobBin = atob(dataURL.split(",")[1]); const array = []; diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index 6e78f182a7..23f6df14c0 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -102,6 +102,7 @@ export default class DeliverWebhookTask extends BaseTask { case "api_keys.create": case "api_keys.delete": case "attachments.create": + case "attachments.update": case "attachments.delete": case "subscriptions.create": case "subscriptions.delete": diff --git a/server/queues/tasks/UploadAttachmentFromUrlTask.ts b/server/queues/tasks/UploadAttachmentFromUrlTask.ts new file mode 100644 index 0000000000..3cfbf0f82a --- /dev/null +++ b/server/queues/tasks/UploadAttachmentFromUrlTask.ts @@ -0,0 +1,51 @@ +import { createContext } from "@server/context"; +import { Attachment } from "@server/models"; +import FileStorage from "@server/storage/files"; +import BaseTask, { TaskPriority } from "./BaseTask"; + +type Props = { + /** The ID of the attachment */ + attachmentId: string; + /** The remote URL to upload */ + url: string; +}; + +/** + * A task that uploads the provided url to a known attachment. + */ +export default class UploadAttachmentFromUrlTask extends BaseTask { + public async perform(props: Props) { + const attachment = await Attachment.findByPk(props.attachmentId, { + rejectOnEmpty: true, + include: [{ association: "user" }], + }); + + try { + const res = await FileStorage.storeFromUrl( + props.url, + attachment.key, + attachment.acl + ); + + if (res?.url) { + const ctx = createContext({ user: attachment.user }); + await attachment.updateWithCtx(ctx, { + url: res.url, + size: res.contentLength, + contentType: res.contentType, + }); + } + } catch (err) { + return { error: err.message }; + } + + return {}; + } + + public get options() { + return { + attempts: 3, + priority: TaskPriority.Normal, + }; + } +} diff --git a/server/routes/api/attachments/attachments.ts b/server/routes/api/attachments/attachments.ts index c978edba20..d2288c2151 100644 --- a/server/routes/api/attachments/attachments.ts +++ b/server/routes/api/attachments/attachments.ts @@ -1,9 +1,14 @@ import Router from "koa-router"; import { v4 as uuidv4 } from "uuid"; import { AttachmentPreset } from "@shared/types"; -import { bytesToHumanReadable } from "@shared/utils/files"; +import { bytesToHumanReadable, getFileNameFromUrl } from "@shared/utils/files"; import { AttachmentValidation } from "@shared/validations"; -import { AuthorizationError, ValidationError } from "@server/errors"; +import { createContext } from "@server/context"; +import { + AuthorizationError, + InvalidRequestError, + ValidationError, +} from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import { transaction } from "@server/middlewares/transaction"; @@ -12,6 +17,8 @@ import { Attachment, Document } from "@server/models"; import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; import { authorize } from "@server/policies"; import { presentAttachment } from "@server/presenters"; +import UploadAttachmentFromUrlTask from "@server/queues/tasks/UploadAttachmentFromUrlTask"; +import { sequelize } from "@server/storage/database"; import FileStorage from "@server/storage/files"; import BaseStorage from "@server/storage/files/BaseStorage"; import { APIContext } from "@server/types"; @@ -105,6 +112,76 @@ router.post( } ); +router.post( + "attachments.createFromUrl", + rateLimiter(RateLimiterStrategy.TwentyFivePerMinute), + auth(), + validate(T.AttachmentsCreateFromUrlSchema), + async (ctx: APIContext) => { + const { url, documentId, preset } = ctx.input.body; + const { user, type } = ctx.state.auth; + + if (preset !== AttachmentPreset.DocumentAttachment || !documentId) { + throw ValidationError( + "Only document attachments can be created from a URL" + ); + } + + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "update", document); + + const name = getFileNameFromUrl(url) ?? "file"; + const modelId = uuidv4(); + const acl = AttachmentHelper.presetToAcl(preset); + const key = AttachmentHelper.getKey({ + acl, + id: modelId, + name, + userId: user.id, + }); + + // Does not use transaction middleware, as attachment must be persisted + // before the job is scheduled. + const attachment = await sequelize.transaction(async (transaction) => + Attachment.createWithCtx( + createContext({ + authType: type, + user, + ip: ctx.ip, + transaction, + }), + { + id: modelId, + key, + acl, + size: 0, + expiresAt: AttachmentHelper.presetToExpiry(preset), + contentType: "application/octet-stream", + documentId, + teamId: user.teamId, + userId: user.id, + } + ) + ); + + const job = await UploadAttachmentFromUrlTask.schedule({ + attachmentId: attachment.id, + url, + }); + + const response = await job.finished(); + if ("error" in response) { + throw InvalidRequestError(response.error); + } + + ctx.body = { + data: presentAttachment(attachment), + }; + } +); + router.post( "attachments.delete", auth(), diff --git a/server/routes/api/attachments/schema.ts b/server/routes/api/attachments/schema.ts index c01c20699d..86a432a6ee 100644 --- a/server/routes/api/attachments/schema.ts +++ b/server/routes/api/attachments/schema.ts @@ -26,6 +26,25 @@ export const AttachmentsCreateSchema = BaseSchema.extend({ export type AttachmentCreateReq = z.infer; +export const AttachmentsCreateFromUrlSchema = BaseSchema.extend({ + body: z.object({ + /** Attachment url */ + url: z.string(), + + /** Id of the document to which the Attachment belongs */ + documentId: z.string().uuid().optional(), + + /** Attachment type */ + preset: z + .nativeEnum(AttachmentPreset) + .default(AttachmentPreset.DocumentAttachment), + }), +}); + +export type AttachmentCreateFromUrlReq = z.infer< + typeof AttachmentsCreateFromUrlSchema +>; + export const AttachmentDeleteSchema = BaseSchema.extend({ body: z.object({ /** Id of the attachment to be deleted */ diff --git a/server/storage/files/BaseStorage.ts b/server/storage/files/BaseStorage.ts index ce0287ebc7..339262cc56 100644 --- a/server/storage/files/BaseStorage.ts +++ b/server/storage/files/BaseStorage.ts @@ -129,13 +129,15 @@ export default abstract class BaseStorage { * @param key The path to store the file at * @param acl The ACL to use * @param init Optional fetch options to use + * @param options Optional upload options * @returns A promise that resolves when the file is uploaded */ public async storeFromUrl( url: string, key: string, acl: string, - init?: RequestInit + init?: RequestInit, + options?: { maxUploadSize?: number } ): Promise< | { url: string; @@ -162,7 +164,10 @@ export default abstract class BaseStorage { const res = await fetch(url, { follow: 3, redirect: "follow", - size: env.FILE_STORAGE_UPLOAD_MAX_SIZE, + size: Math.min( + options?.maxUploadSize ?? Infinity, + env.FILE_STORAGE_UPLOAD_MAX_SIZE + ), timeout: 10000, ...init, }); diff --git a/server/types.ts b/server/types.ts index aca8db6760..3b715a2bf2 100644 --- a/server/types.ts +++ b/server/types.ts @@ -116,6 +116,10 @@ export type AttachmentEvent = BaseEvent & source?: "import"; }; } + | { + name: "attachments.update"; + modelId: string; + } | { name: "attachments.delete"; modelId: string; diff --git a/shared/editor/commands/createAndInsertLink.ts b/shared/editor/commands/createAndInsertLink.ts deleted file mode 100644 index fa62326ec1..0000000000 --- a/shared/editor/commands/createAndInsertLink.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Node } from "prosemirror-model"; -import { EditorView } from "prosemirror-view"; -import { toast } from "sonner"; -import type { Dictionary } from "~/hooks/useDictionary"; - -function findPlaceholderLink(doc: Node, href: string) { - let result: { pos: number; node: Node } | undefined; - - doc.descendants((node: Node, pos = 0) => { - // get text nodes - if (node.type.name === "text") { - // get marks for text nodes - node.marks.forEach((mark) => { - // any of the marks links? - if (mark.type.name === "link") { - // any of the links to other docs? - if (mark.attrs.href === href) { - result = { node, pos }; - } - } - }); - - return false; - } - - if (!node.content.size) { - return false; - } - - return true; - }); - - return result; -} - -const createAndInsertLink = async function ( - view: EditorView, - title: string, - href: string, - options: { - dictionary: Dictionary; - nested?: boolean; - onCreateLink: (title: string, nested?: boolean) => Promise; - } -) { - const { dispatch, state } = view; - const { onCreateLink } = options; - - try { - const url = await onCreateLink(title, options.nested); - const result = findPlaceholderLink(view.state.doc, href); - - if (!result) { - return; - } - - dispatch( - view.state.tr - .removeMark( - result.pos, - result.pos + result.node.nodeSize, - state.schema.marks.link - ) - .addMark( - result.pos, - result.pos + result.node.nodeSize, - state.schema.marks.link.create({ href: url }) - ) - ); - } catch (err) { - const result = findPlaceholderLink(view.state.doc, href); - if (!result) { - return; - } - - dispatch( - view.state.tr.removeMark( - result.pos, - result.pos + result.node.nodeSize, - state.schema.marks.link - ) - ); - - toast.error(options.dictionary.createLinkError); - } -}; - -export default createAndInsertLink; diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts index 35e0f9c933..21b1be342e 100644 --- a/shared/editor/commands/insertFiles.ts +++ b/shared/editor/commands/insertFiles.ts @@ -16,7 +16,7 @@ export type Options = { /** Set to true to replace any existing image at the users selection */ replaceExisting?: boolean; /** Callback fired to upload a file */ - uploadFile?: (file: File) => Promise; + uploadFile?: (file: File | string) => Promise; /** Callback fired when the user starts a file upload */ onFileUploadStart?: () => void; /** Callback fired when the user completes a file upload */ diff --git a/shared/editor/extensions/Mermaid.ts b/shared/editor/extensions/Mermaid.ts index 33bf707f84..be18366c50 100644 --- a/shared/editor/extensions/Mermaid.ts +++ b/shared/editor/extensions/Mermaid.ts @@ -13,7 +13,8 @@ import { Decoration, DecorationSet } from "prosemirror-view"; import { v4 as uuidv4 } from "uuid"; import { isCode } from "../lib/isCode"; import { isRemoteTransaction } from "../lib/multiplayer"; -import { findBlockNodes, NodeWithPos } from "../queries/findChildren"; +import { findBlockNodes } from "../queries/findChildren"; +import { NodeWithPos } from "../types"; type MermaidState = { decorationSet: DecorationSet; diff --git a/shared/editor/lib/FileHelper.ts b/shared/editor/lib/FileHelper.ts index e11f94941f..93b2bb94a5 100644 --- a/shared/editor/lib/FileHelper.ts +++ b/shared/editor/lib/FileHelper.ts @@ -21,6 +21,23 @@ export default class FileHelper { return file.type.startsWith("video/"); } + /** + * Download a file from a URL and return it as a File object. + * + * @param url The URL to download the file from + * @returns The downloaded file + */ + static async getFileForUrl(url: string): Promise { + const response = await fetch(url); + const blob = await response.blob(); + const fileName = (response.headers.get("content-disposition") || "").split( + "filename=" + )[1]; + return new File([blob], fileName || "file", { + type: blob.type, + }); + } + /** * Loads the dimensions of a video file. * diff --git a/shared/editor/plugins/UploadPlugin.ts b/shared/editor/plugins/UploadPlugin.ts index c50ca3adf0..26c2e898c8 100644 --- a/shared/editor/plugins/UploadPlugin.ts +++ b/shared/editor/plugins/UploadPlugin.ts @@ -1,8 +1,10 @@ import { extension } from "mime-types"; +import { Node } from "prosemirror-model"; import { Plugin } from "prosemirror-state"; import { getDataTransferFiles, getDataTransferImage } from "../../utils/files"; import { fileNameFromUrl, isInternalUrl } from "../../utils/urls"; import insertFiles, { Options } from "../commands/insertFiles"; +import FileHelper from "../lib/FileHelper"; export class UploadPlugin extends Plugin { constructor(options: Options) { @@ -96,6 +98,44 @@ export class UploadPlugin extends Plugin { return false; }, }, + transformPasted: (slice, view) => { + // find any remote images in pasted slice, but leave it alone. + const images: Node[] = []; + slice.content.descendants((node) => { + if (node.type.name === "image" && !isInternalUrl(node.attrs.src)) { + images.push(node); + } + }); + + // Upload each remote image to our storage and replace the src + // with the new url and dimensions. + void images.map(async (image) => { + const url = await options.uploadFile?.(image.attrs.src); + + if (url) { + const file = await FileHelper.getFileForUrl(url); + const dimensions = await FileHelper.getImageDimensions(file); + const { tr } = view.state; + + tr.doc.nodesBetween(0, tr.doc.nodeSize - 2, (node, pos) => { + if ( + node.type.name === "image" && + node.attrs.src === image.attrs.src + ) { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + ...dimensions, + src: url, + }); + } + }); + + view.dispatch(tr); + } + }); + + return slice; + }, }, }); } diff --git a/shared/editor/queries/findChildren.ts b/shared/editor/queries/findChildren.ts index c3757a1ae0..506cb50828 100644 --- a/shared/editor/queries/findChildren.ts +++ b/shared/editor/queries/findChildren.ts @@ -1,12 +1,8 @@ import { Node } from "prosemirror-model"; +import { NodeWithPos } from "../types"; type Predicate = (node: Node) => boolean; -export type NodeWithPos = { - pos: number; - node: Node; -}; - export function flatten(node: Node, descend = true): NodeWithPos[] { if (!node) { throw new Error('Invalid "node" parameter'); diff --git a/shared/editor/queries/findCollapsedNodes.ts b/shared/editor/queries/findCollapsedNodes.ts index ad70bd9821..7e0e9e8b5d 100644 --- a/shared/editor/queries/findCollapsedNodes.ts +++ b/shared/editor/queries/findCollapsedNodes.ts @@ -1,5 +1,6 @@ import { Node } from "prosemirror-model"; -import { findBlockNodes, NodeWithPos } from "./findChildren"; +import { NodeWithPos } from "../types"; +import { findBlockNodes } from "./findChildren"; export function findCollapsedNodes(doc: Node): NodeWithPos[] { const blocks = findBlockNodes(doc); diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 8e8687ab9d..f9d868d4f2 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -6,6 +6,11 @@ import * as React from "react"; import { DefaultTheme } from "styled-components"; import { Primitive } from "utility-types"; +export type NodeWithPos = { + pos: number; + node: ProsemirrorNode; +}; + export type PlainTextSerializer = (node: ProsemirrorNode) => string; export enum TableLayout { diff --git a/shared/utils/files.test.ts b/shared/utils/files.test.ts index fb4faa9fea..b9aa073a01 100644 --- a/shared/utils/files.test.ts +++ b/shared/utils/files.test.ts @@ -1,7 +1,7 @@ -import { bytesToHumanReadable } from "./files"; +import { bytesToHumanReadable, getFileNameFromUrl } from "./files"; describe("bytesToHumanReadable", () => { - test("Outputs readable string", () => { + it("outputs readable string", () => { expect(bytesToHumanReadable(0)).toBe("0 Bytes"); expect(bytesToHumanReadable(0.0)).toBe("0 Bytes"); expect(bytesToHumanReadable(33)).toBe("33 Bytes"); @@ -15,3 +15,20 @@ describe("bytesToHumanReadable", () => { expect(bytesToHumanReadable(undefined)).toBe("0 Bytes"); }); }); + +describe("getFileNameFromUrl", () => { + it("returns the filename from a URL", () => { + expect(getFileNameFromUrl("https://example.com/file")).toBe("file"); + expect(getFileNameFromUrl("https://example.com/file.txt")).toBe("file.txt"); + expect( + getFileNameFromUrl("https://example.com/file.txt?query=string") + ).toBe("file.txt"); + expect(getFileNameFromUrl("https://example.com/file.txt#hash")).toBe( + "file.txt" + ); + expect( + getFileNameFromUrl("https://example.com/file.txt?query=string#hash") + ).toBe("file.txt"); + expect(getFileNameFromUrl("https://example.com/")).toBe(""); + }); +}); diff --git a/shared/utils/files.ts b/shared/utils/files.ts index 43873d1a0d..17bd53b5e7 100644 --- a/shared/utils/files.ts +++ b/shared/utils/files.ts @@ -112,3 +112,20 @@ export function getEventFiles( ? Array.prototype.slice.call(event.target.files) : []; } + +/** + * Get the likely filename from a URL + * + * @param url The URL to get the filename from + * @returns The filename or null if it could not be determined + */ +export function getFileNameFromUrl(url: string) { + try { + const urlObj = new URL(url); + const pathname = urlObj.pathname; + const filename = pathname.substring(pathname.lastIndexOf("/") + 1); + return filename; + } catch (error) { + return null; + } +}