feat: Upload remote-hosted images on paste (#8301)

* First pass

* fix

* tidy, tidy

* Determine dimensions

* docs

* test getFileNameFromUrl

* PR feedback

* tsc
This commit is contained in:
Tom Moor
2025-01-30 20:24:07 -05:00
committed by GitHub
parent abaeba5952
commit 28aebc9fbf
18 changed files with 304 additions and 106 deletions

View File

@@ -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<SharedEditor> | null) {
const previousCommentIds = React.useRef<string[]>();
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]

View File

@@ -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 = [];

View File

@@ -102,6 +102,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "api_keys.create":
case "api_keys.delete":
case "attachments.create":
case "attachments.update":
case "attachments.delete":
case "subscriptions.create":
case "subscriptions.delete":

View File

@@ -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<Props> {
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,
};
}
}

View File

@@ -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<T.AttachmentCreateFromUrlReq>) => {
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(),

View File

@@ -26,6 +26,25 @@ export const AttachmentsCreateSchema = BaseSchema.extend({
export type AttachmentCreateReq = z.infer<typeof AttachmentsCreateSchema>;
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 */

View File

@@ -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,
});

View File

@@ -116,6 +116,10 @@ export type AttachmentEvent = BaseEvent<Attachment> &
source?: "import";
};
}
| {
name: "attachments.update";
modelId: string;
}
| {
name: "attachments.delete";
modelId: string;

View File

@@ -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<string>;
}
) {
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;

View File

@@ -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<string>;
uploadFile?: (file: File | string) => Promise<string>;
/** Callback fired when the user starts a file upload */
onFileUploadStart?: () => void;
/** Callback fired when the user completes a file upload */

View File

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

View File

@@ -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<File> {
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.
*

View File

@@ -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;
},
},
});
}

View File

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

View File

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

View File

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

View File

@@ -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("");
});
});

View File

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