mirror of
https://github.com/outline/outline.git
synced 2026-02-25 05:49:36 -06:00
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:
@@ -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]
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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":
|
||||
|
||||
51
server/queues/tasks/UploadAttachmentFromUrlTask.ts
Normal file
51
server/queues/tasks/UploadAttachmentFromUrlTask.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -116,6 +116,10 @@ export type AttachmentEvent = BaseEvent<Attachment> &
|
||||
source?: "import";
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: "attachments.update";
|
||||
modelId: string;
|
||||
}
|
||||
| {
|
||||
name: "attachments.delete";
|
||||
modelId: string;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user