fix: Handle base64 and remote images when creating a file (#5740)
This commit is contained in:
@@ -24,10 +24,10 @@
|
||||
"db:rollback": "sequelize db:migrate:undo",
|
||||
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
|
||||
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
|
||||
"test": "jest --config=.jestconfig.json --forceExit",
|
||||
"test:app": "jest --config=.jestconfig.json --selectProjects app",
|
||||
"test:shared": "jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom",
|
||||
"test:server": "jest --config=.jestconfig.json --selectProjects server",
|
||||
"test": "TZ=UTC jest --config=.jestconfig.json --forceExit",
|
||||
"test:app": "TZ=UTC jest --config=.jestconfig.json --selectProjects app",
|
||||
"test:shared": "TZ=UTC jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom",
|
||||
"test:server": "TZ=UTC jest --config=.jestconfig.json --selectProjects server",
|
||||
"vite:dev": "vite",
|
||||
"vite:build": "vite build",
|
||||
"vite:preview": "vite preview"
|
||||
|
||||
@@ -1,51 +1,98 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Attachment, Event, User } from "@server/models";
|
||||
import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
|
||||
import FileStorage from "@server/storage/files";
|
||||
|
||||
type BaseProps = {
|
||||
id?: string;
|
||||
name: string;
|
||||
user: User;
|
||||
source?: "import";
|
||||
preset: AttachmentPreset;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
type UrlProps = BaseProps & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type BufferProps = BaseProps & {
|
||||
buffer: Buffer;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type Props = UrlProps | BufferProps;
|
||||
|
||||
export default async function attachmentCreator({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
buffer,
|
||||
user,
|
||||
source,
|
||||
preset,
|
||||
ip,
|
||||
transaction,
|
||||
}: {
|
||||
id?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
buffer: Buffer;
|
||||
user: User;
|
||||
source?: "import";
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
}) {
|
||||
const key = `uploads/${user.id}/${uuidv4()}/${name}`;
|
||||
const acl = process.env.AWS_S3_ACL || "private";
|
||||
const url = await FileStorage.upload({
|
||||
body: buffer,
|
||||
contentType: type,
|
||||
contentLength: buffer.length,
|
||||
key,
|
||||
...rest
|
||||
}: Props): Promise<Attachment | undefined> {
|
||||
const acl = AttachmentHelper.presetToAcl(preset);
|
||||
const key = AttachmentHelper.getKey({
|
||||
acl,
|
||||
id: uuidv4(),
|
||||
name,
|
||||
userId: user.id,
|
||||
});
|
||||
const attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
|
||||
let attachment;
|
||||
|
||||
if ("url" in rest) {
|
||||
const { url } = rest;
|
||||
const res = await FileStorage.uploadFromUrl(url, key, acl);
|
||||
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: res.contentLength,
|
||||
contentType: res.contentType,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const { buffer, type } = rest;
|
||||
await FileStorage.upload({
|
||||
body: buffer,
|
||||
contentType: type,
|
||||
contentLength: buffer.length,
|
||||
key,
|
||||
acl,
|
||||
url,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
attachment = await Attachment.create(
|
||||
{
|
||||
id,
|
||||
key,
|
||||
acl,
|
||||
size: buffer.length,
|
||||
contentType: type,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "attachments.create",
|
||||
@@ -62,5 +109,6 @@ export default async function attachmentCreator({
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,15 @@ export default async function documentCreator({
|
||||
title: templateDocument
|
||||
? DocumentHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: title,
|
||||
text: templateDocument ? templateDocument.text : text,
|
||||
text: await DocumentHelper.replaceImagesWithAttachments(
|
||||
DocumentHelper.replaceTemplateVariables(
|
||||
templateDocument ? templateDocument.text : text,
|
||||
user
|
||||
),
|
||||
user,
|
||||
ip,
|
||||
transaction
|
||||
),
|
||||
state,
|
||||
},
|
||||
{
|
||||
@@ -112,7 +120,11 @@ export default async function documentCreator({
|
||||
);
|
||||
|
||||
if (publish) {
|
||||
await document.publish(user.id, collectionId!, { transaction });
|
||||
if (!collectionId) {
|
||||
throw new Error("Collection ID is required to publish");
|
||||
}
|
||||
|
||||
await document.publish(user.id, collectionId, { transaction });
|
||||
await Event.create(
|
||||
{
|
||||
name: "documents.publish",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path";
|
||||
import emojiRegex from "emoji-regex";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import truncate from "lodash/truncate";
|
||||
import mammoth from "mammoth";
|
||||
import quotedPrintable from "quoted-printable";
|
||||
@@ -10,12 +9,10 @@ import parseTitle from "@shared/utils/parseTitle";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { User } from "@server/models";
|
||||
import DocumentHelper from "@server/models/helpers/DocumentHelper";
|
||||
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
|
||||
import dataURItoBuffer from "@server/utils/dataURItoBuffer";
|
||||
import parseImages from "@server/utils/parseImages";
|
||||
import turndownService from "@server/utils/turndown";
|
||||
import { FileImportError, InvalidRequestError } from "../errors";
|
||||
import attachmentCreator from "./attachmentCreator";
|
||||
|
||||
interface ImportableFile {
|
||||
type: string;
|
||||
@@ -207,26 +204,12 @@ async function documentImporter({
|
||||
// to match our hardbreak parser.
|
||||
text = text.replace(/<br>/gi, "\\n");
|
||||
|
||||
// find data urls, convert to blobs, upload and write attachments
|
||||
const images = parseImages(text);
|
||||
const dataURIs = images.filter((href) => href.startsWith("data:"));
|
||||
|
||||
for (const uri of dataURIs) {
|
||||
const name = "imported";
|
||||
const { buffer, type } = dataURItoBuffer(uri);
|
||||
const attachment = await attachmentCreator({
|
||||
name,
|
||||
type,
|
||||
buffer,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
});
|
||||
text = text.replace(
|
||||
new RegExp(escapeRegExp(uri), "g"),
|
||||
attachment.redirectUrl
|
||||
);
|
||||
}
|
||||
text = await DocumentHelper.replaceImagesWithAttachments(
|
||||
text,
|
||||
user,
|
||||
ip,
|
||||
transaction
|
||||
);
|
||||
|
||||
// It's better to truncate particularly long titles than fail the import
|
||||
title = truncate(title, { length: DocumentValidation.maxTitleLength });
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import Revision from "@server/models/Revision";
|
||||
import { buildDocument } from "@server/test/factories";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import DocumentHelper from "./DocumentHelper";
|
||||
|
||||
describe("DocumentHelper", () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(Date.parse("2021-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("replaceTemplateVariables", () => {
|
||||
it("should replace {time} with current time", async () => {
|
||||
const user = await buildUser();
|
||||
const result = DocumentHelper.replaceTemplateVariables(
|
||||
"Hello {time}",
|
||||
user
|
||||
);
|
||||
|
||||
expect(result).toBe("Hello 12 00 AM");
|
||||
});
|
||||
|
||||
it("should replace {date} with current date", async () => {
|
||||
const user = await buildUser();
|
||||
const result = DocumentHelper.replaceTemplateVariables(
|
||||
"Hello {date}",
|
||||
user
|
||||
);
|
||||
|
||||
expect(result).toBe("Hello January 1 2021");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMentions", () => {
|
||||
it("should not parse normal links as mentions", async () => {
|
||||
const document = await buildDocument({
|
||||
|
||||
@@ -6,14 +6,17 @@ import { JSDOM } from "jsdom";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import startCase from "lodash/startCase";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { Transaction } from "sequelize";
|
||||
import * as Y from "yjs";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
unicodeCLDRtoBCP47,
|
||||
} from "@shared/utils/date";
|
||||
import attachmentCreator from "@server/commands/attachmentCreator";
|
||||
import { parser, schema } from "@server/editor";
|
||||
import { trace } from "@server/logging/tracing";
|
||||
import type Document from "@server/models/Document";
|
||||
@@ -22,6 +25,7 @@ import User from "@server/models/User";
|
||||
import FileStorage from "@server/storage/files";
|
||||
import diff from "@server/utils/diff";
|
||||
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
||||
import parseImages from "@server/utils/parseImages";
|
||||
import Attachment from "../Attachment";
|
||||
import ProsemirrorHelper from "./ProsemirrorHelper";
|
||||
|
||||
@@ -351,9 +355,51 @@ export default class DocumentHelper {
|
||||
: undefined;
|
||||
|
||||
return text
|
||||
.replace("{date}", startCase(getCurrentDateAsString(locales)))
|
||||
.replace("{time}", startCase(getCurrentTimeAsString(locales)))
|
||||
.replace("{datetime}", startCase(getCurrentDateTimeAsString(locales)));
|
||||
.replace(/{date}/g, startCase(getCurrentDateAsString(locales)))
|
||||
.replace(/{time}/g, startCase(getCurrentTimeAsString(locales)))
|
||||
.replace(/{datetime}/g, startCase(getCurrentDateTimeAsString(locales)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces remote and base64 encoded images in the given text with attachment
|
||||
* urls and uploads the images to the storage provider.
|
||||
*
|
||||
* @param text The text to replace the images in
|
||||
* @param user The user context
|
||||
* @param ip The IP address of the user
|
||||
* @param transaction The transaction to use for the database operations
|
||||
* @returns The text with the images replaced
|
||||
*/
|
||||
static async replaceImagesWithAttachments(
|
||||
text: string,
|
||||
user: User,
|
||||
ip?: string,
|
||||
transaction?: Transaction
|
||||
) {
|
||||
let output = text;
|
||||
const images = parseImages(text);
|
||||
|
||||
await Promise.all(
|
||||
images.map(async (image) => {
|
||||
const attachment = await attachmentCreator({
|
||||
name: image.alt ?? "image",
|
||||
url: image.src,
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
user,
|
||||
ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
output = output.replace(
|
||||
new RegExp(escapeRegExp(image.src), "g"),
|
||||
attachment.redirectUrl
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import truncate from "lodash/truncate";
|
||||
import {
|
||||
AttachmentPreset,
|
||||
CollectionPermission,
|
||||
CollectionSort,
|
||||
FileOperationState,
|
||||
@@ -243,6 +244,7 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
Logger.debug("task", `ImportTask persisting attachment ${item.id}`);
|
||||
const attachment = await attachmentCreator({
|
||||
source: "import",
|
||||
preset: AttachmentPreset.DocumentAttachment,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.mimeType,
|
||||
@@ -251,7 +253,9 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
ip,
|
||||
transaction,
|
||||
});
|
||||
attachments.set(item.id, attachment);
|
||||
if (attachment) {
|
||||
attachments.set(item.id, attachment);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ export default class UploadTeamAvatarTask extends BaseTask<Props> {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const avatarUrl = await FileStorage.uploadFromUrl(
|
||||
const res = await FileStorage.uploadFromUrl(
|
||||
props.avatarUrl,
|
||||
`avatars/${team.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
|
||||
if (avatarUrl) {
|
||||
await team.update({ avatarUrl });
|
||||
if (res?.url) {
|
||||
await team.update({ avatarUrl: res?.url });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,14 @@ export default class UploadUserAvatarTask extends BaseTask<Props> {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const avatarUrl = await FileStorage.uploadFromUrl(
|
||||
const res = await FileStorage.uploadFromUrl(
|
||||
props.avatarUrl,
|
||||
`avatars/${user.id}/${uuidv4()}`,
|
||||
"public-read"
|
||||
);
|
||||
|
||||
if (avatarUrl) {
|
||||
await user.update({ avatarUrl });
|
||||
if (res?.url) {
|
||||
await user.update({ avatarUrl: res?.url });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Readable } from "stream";
|
||||
import { PresignedPost } from "aws-sdk/clients/s3";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import fetch from "@server/utils/fetch";
|
||||
|
||||
@@ -78,31 +79,87 @@ export default abstract class BaseStorage {
|
||||
}): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Upload a file to the storage provider directly from a remote URL.
|
||||
* Upload a file to the storage provider directly from a remote or base64 encoded URL.
|
||||
*
|
||||
* @param url The URL to upload from
|
||||
* @param key The path to store the file at
|
||||
* @param acl The ACL to use
|
||||
* @returns The URL of the file
|
||||
* @returns A promise that resolves when the file is uploaded
|
||||
*/
|
||||
public async uploadFromUrl(url: string, key: string, acl: string) {
|
||||
public async uploadFromUrl(
|
||||
url: string,
|
||||
key: string,
|
||||
acl: string
|
||||
): Promise<
|
||||
| {
|
||||
url: string;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const endpoint = this.getPublicEndpoint(true);
|
||||
if (url.startsWith("/api") || url.startsWith(endpoint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let buffer, contentLength, contentType;
|
||||
const match = url.match(/data:(.*);base64,(.*)/);
|
||||
|
||||
if (match) {
|
||||
contentType = match[1];
|
||||
buffer = Buffer.from(match[2], "base64");
|
||||
contentLength = buffer.byteLength;
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
follow: 3,
|
||||
redirect: "follow",
|
||||
size: env.AWS_S3_UPLOAD_MAX_SIZE,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Error fetching URL to upload: ${res.status}`);
|
||||
}
|
||||
|
||||
buffer = await res.buffer();
|
||||
|
||||
contentType =
|
||||
res.headers.get("content-type") ?? "application/octet-stream";
|
||||
contentLength = parseInt(res.headers.get("content-length") ?? "0", 10);
|
||||
} catch (err) {
|
||||
Logger.error("Error fetching URL to upload", err, {
|
||||
url,
|
||||
key,
|
||||
acl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const buffer = await res.buffer();
|
||||
return this.upload({
|
||||
const result = await this.upload({
|
||||
body: buffer,
|
||||
contentLength: res.headers["content-length"],
|
||||
contentType: res.headers["content-type"],
|
||||
contentLength,
|
||||
contentType,
|
||||
key,
|
||||
acl,
|
||||
});
|
||||
|
||||
return result
|
||||
? {
|
||||
url: result,
|
||||
contentType,
|
||||
contentLength,
|
||||
}
|
||||
: undefined;
|
||||
} catch (err) {
|
||||
Logger.error("Error uploading to S3 from URL", err, {
|
||||
Logger.error("Error uploading to file storage from URL", err, {
|
||||
url,
|
||||
key,
|
||||
acl,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import dataURItoBuffer from "./dataURItoBuffer";
|
||||
|
||||
it("should parse value data URI", () => {
|
||||
const response = dataURItoBuffer(
|
||||
`data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAB+FBMVEUAAAA/mUPidDHiLi5Cn0XkNTPmeUrkdUg/m0Q0pEfcpSbwaVdKskg+lUP4zA/iLi3msSHkOjVAmETdJSjtYFE/lkPnRj3sWUs8kkLeqCVIq0fxvhXqUkbVmSjwa1n1yBLepyX1xxP0xRXqUkboST9KukpHpUbuvRrzrhF/ljbwaljuZFM4jELaoSdLtElJrUj1xxP6zwzfqSU4i0HYnydMtUlIqUfywxb60AxZqEXaoifgMCXptR9MtklHpEY2iUHWnSjvvRr70QujkC+pUC/90glMuEnlOjVMt0j70QriLS1LtEnnRj3qUUXfIidOjsxAhcZFo0bjNDH0xxNLr0dIrUdmntVTkMoyfL8jcLBRuErhJyrgKyb4zA/5zg3tYFBBmUTmQTnhMinruBzvvhnxwxZ/st+Ktt5zp9hqota2vtK6y9FemNBblc9HiMiTtMbFtsM6gcPV2r6dwroseLrMrbQrdLGdyKoobKbo3Zh+ynrgVllZulTsXE3rV0pIqUf42UVUo0JyjEHoS0HmsiHRGR/lmRz/1hjqnxjvpRWfwtOhusaz0LRGf7FEfbDVmqHXlJeW0pbXq5bec3fX0nTnzmuJuWvhoFFhm0FtrziBsjaAaDCYWC+uSi6jQS3FsSfLJiTirCOkuCG1KiG+wSC+GBvgyhTszQ64Z77KAAAARXRSTlMAIQRDLyUgCwsE6ebm5ubg2dLR0byXl4FDQzU1NDEuLSUgC+vr6urq6ubb29vb2tra2tG8vLu7u7uXl5eXgYGBgYGBLiUALabIAAABsElEQVQoz12S9VPjQBxHt8VaOA6HE+AOzv1wd7pJk5I2adpCC7RUcHd3d3fXf5PvLkxheD++z+yb7GSRlwD/+Hj/APQCZWxM5M+goF+RMbHK594v+tPoiN1uHxkt+xzt9+R9wnRTZZQpXQ0T5uP1IQxToyOAZiQu5HEpjeA4SWIoksRxNiGC1tRZJ4LNxgHgnU5nJZBDvuDdl8lzQRBsQ+s9PZt7s7Pz8wsL39/DkIfZ4xlB2Gqsq62ta9oxVlVrNZpihFRpGO9fzQw1ms0NDWZz07iGkJmIFH8xxkc3a/WWlubmFkv9AB2SEpDvKxbjidN2faseaNV3zoHXvv7wMODJdkOHAegweAfFPx4G67KluxzottCU9n8CUqXzcIQdXOytAHqXxomvykhEKN9EFutG22p//0rbNvHVxiJywa8yS2KDfV1dfbu31H8jF1RHiTKtWYeHxUvq3bn0pyjCRaiRU6aDO+gb3aEfEeVNsDgm8zzLy9egPa7Qt8TSJdwhjplk06HH43ZNJ3s91KKCHQ5x4sw1fRGYDZ0n1L4FKb9/BP5JLYxToheoFCVxz57PPS8UhhEpLBVeAAAAAElFTkSuQmCC`
|
||||
);
|
||||
expect(response.buffer).toBeTruthy();
|
||||
expect(response.type).toBe("image/png");
|
||||
});
|
||||
it("should throw an error with junk input", () => {
|
||||
let err;
|
||||
|
||||
try {
|
||||
dataURItoBuffer("what");
|
||||
} catch (error) {
|
||||
err = error;
|
||||
}
|
||||
|
||||
expect(err).toBeTruthy();
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
export default function dataURItoBuffer(dataURI: string) {
|
||||
const split = dataURI.split(",");
|
||||
|
||||
if (!dataURI.startsWith("data") || split.length <= 1) {
|
||||
throw new Error("Not a dataURI");
|
||||
}
|
||||
|
||||
// separate out the mime component
|
||||
const type = split[0].split(":")[1].split(";")[0];
|
||||
// convert base64 to buffer
|
||||
const buffer = Buffer.from(split[1], "base64");
|
||||
return {
|
||||
buffer,
|
||||
type,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,8 @@ it("should return an array of images", () => {
|
||||

|
||||
`);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe("/attachments/image.png");
|
||||
expect(result[0].alt).toBe("internal");
|
||||
expect(result[0].src).toBe("/attachments/image.png");
|
||||
});
|
||||
|
||||
it("should return deeply nested images", () => {
|
||||
@@ -18,10 +19,11 @@ it("should return deeply nested images", () => {
|
||||
|
||||
- one
|
||||
- two
|
||||
- three 
|
||||
- three 
|
||||
`);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]).toBe("/attachments/image.png");
|
||||
expect(result[0].alt).toBe("oh my");
|
||||
expect(result[0].src).toBe("/attachments/image.png");
|
||||
});
|
||||
|
||||
it("should not return non document links", () => {
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { parser } from "@server/editor";
|
||||
|
||||
export default function parseImages(text: string): string[] {
|
||||
type ImageProps = { src: string; alt: string };
|
||||
|
||||
/**
|
||||
* Parses a string of markdown and returns a list of images.
|
||||
*
|
||||
* @param text The markdown to parse
|
||||
* @returns A unique list of images
|
||||
*/
|
||||
export default function parseImages(text: string): ImageProps[] {
|
||||
const doc = parser.parse(text);
|
||||
const images: string[] = [];
|
||||
const images = new Map<string, ImageProps>();
|
||||
|
||||
if (!doc) {
|
||||
return images;
|
||||
return [];
|
||||
}
|
||||
|
||||
doc.descendants((node: Node) => {
|
||||
if (node.type.name === "image") {
|
||||
if (!images.includes(node.attrs.src)) {
|
||||
images.push(node.attrs.src);
|
||||
if (!images.has(node.attrs.src)) {
|
||||
images.set(node.attrs.src, {
|
||||
src: node.attrs.src,
|
||||
alt: node.attrs.alt,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -24,5 +36,5 @@ export default function parseImages(text: string): string[] {
|
||||
return true;
|
||||
});
|
||||
|
||||
return images;
|
||||
return Array.from(images.values());
|
||||
}
|
||||
|
||||
77
yarn.lock
77
yarn.lock
@@ -1879,14 +1879,7 @@
|
||||
strip-ansi "^6.0.0"
|
||||
v8-to-istanbul "^9.0.1"
|
||||
|
||||
"@jest/schemas@^29.6.0":
|
||||
version "29.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.0.tgz#0f4cb2c8e3dca80c135507ba5635a4fd755b0040"
|
||||
integrity sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==
|
||||
dependencies:
|
||||
"@sinclair/typebox" "^0.27.8"
|
||||
|
||||
"@jest/schemas@^29.6.3":
|
||||
"@jest/schemas@^29.6.0", "@jest/schemas@^29.6.3":
|
||||
version "29.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03"
|
||||
integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==
|
||||
@@ -1943,19 +1936,7 @@
|
||||
slash "^3.0.0"
|
||||
write-file-atomic "^4.0.2"
|
||||
|
||||
"@jest/types@^29.5.0", "@jest/types@^29.6.1":
|
||||
version "29.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.1.tgz#ae79080278acff0a6af5eb49d063385aaa897bf2"
|
||||
integrity sha512-tPKQNMPuXgvdOn2/Lg9HNfUvjYVGolt04Hp03f5hAk878uwOLikN+JzeLY0HcVgKgFl9Hs3EIqpu3WX27XNhnw==
|
||||
dependencies:
|
||||
"@jest/schemas" "^29.6.0"
|
||||
"@types/istanbul-lib-coverage" "^2.0.0"
|
||||
"@types/istanbul-reports" "^3.0.0"
|
||||
"@types/node" "*"
|
||||
"@types/yargs" "^17.0.8"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@jest/types@^29.6.3":
|
||||
"@jest/types@^29.5.0", "@jest/types@^29.6.1", "@jest/types@^29.6.3":
|
||||
version "29.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59"
|
||||
integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==
|
||||
@@ -8386,26 +8367,7 @@ jest-get-type@^29.4.3:
|
||||
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5"
|
||||
integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==
|
||||
|
||||
jest-haste-map@^29.6.1:
|
||||
version "29.6.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.6.1.tgz#62655c7a1c1b349a3206441330fb2dbdb4b63803"
|
||||
integrity sha512-0m7f9PZXxOCk1gRACiVgX85knUKPKLPg4oRCjLoqIm9brTHXaorMA0JpmtmVkQiT8nmXyIVoZd/nnH1cfC33ig==
|
||||
dependencies:
|
||||
"@jest/types" "^29.6.1"
|
||||
"@types/graceful-fs" "^4.1.3"
|
||||
"@types/node" "*"
|
||||
anymatch "^3.0.3"
|
||||
fb-watchman "^2.0.0"
|
||||
graceful-fs "^4.2.9"
|
||||
jest-regex-util "^29.4.3"
|
||||
jest-util "^29.6.1"
|
||||
jest-worker "^29.6.1"
|
||||
micromatch "^4.0.4"
|
||||
walker "^1.0.8"
|
||||
optionalDependencies:
|
||||
fsevents "^2.3.2"
|
||||
|
||||
jest-haste-map@^29.6.3:
|
||||
jest-haste-map@^29.6.1, jest-haste-map@^29.6.3:
|
||||
version "29.6.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.6.3.tgz#a53ac35a137fd32d932039aab29d02a9dab30689"
|
||||
integrity sha512-GecR5YavfjkhOytEFHAeI6aWWG3f/cOKNB1YJvj/B76xAmeVjy4zJUYobGF030cRmKaO1FBw3V8CZZ6KVh9ZSw==
|
||||
@@ -8471,12 +8433,7 @@ jest-pnp-resolver@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c"
|
||||
integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==
|
||||
|
||||
jest-regex-util@^29.4.3:
|
||||
version "29.4.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.4.3.tgz#a42616141e0cae052cfa32c169945d00c0aa0bb8"
|
||||
integrity sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg==
|
||||
|
||||
jest-regex-util@^29.6.3:
|
||||
jest-regex-util@^29.4.3, jest-regex-util@^29.6.3:
|
||||
version "29.6.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52"
|
||||
integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==
|
||||
@@ -8586,19 +8543,7 @@ jest-snapshot@^29.6.1:
|
||||
pretty-format "^29.6.1"
|
||||
semver "^7.5.3"
|
||||
|
||||
jest-util@^29.5.0, jest-util@^29.6.1:
|
||||
version "29.6.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.6.1.tgz#c9e29a87a6edbf1e39e6dee2b4689b8a146679cb"
|
||||
integrity sha512-NRFCcjc+/uO3ijUVyNOQJluf8PtGCe/W6cix36+M3cTFgiYqFOOW5MgN4JOOcvbUhcKTYVd1CvHz/LWi8d16Mg==
|
||||
dependencies:
|
||||
"@jest/types" "^29.6.1"
|
||||
"@types/node" "*"
|
||||
chalk "^4.0.0"
|
||||
ci-info "^3.2.0"
|
||||
graceful-fs "^4.2.9"
|
||||
picomatch "^2.2.3"
|
||||
|
||||
jest-util@^29.6.3:
|
||||
jest-util@^29.5.0, jest-util@^29.6.1, jest-util@^29.6.3:
|
||||
version "29.6.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.6.3.tgz#e15c3eac8716440d1ed076f09bc63ace1aebca63"
|
||||
integrity sha512-QUjna/xSy4B32fzcKTSz1w7YYzgiHrjjJjevdRf61HYk998R5vVMMNmrHESYZVDS5DSWs+1srPLPKxXPkeSDOA==
|
||||
@@ -8645,17 +8590,7 @@ jest-worker@^26.2.1:
|
||||
merge-stream "^2.0.0"
|
||||
supports-color "^7.0.0"
|
||||
|
||||
jest-worker@^29.6.1:
|
||||
version "29.6.1"
|
||||
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.6.1.tgz#64b015f0e985ef3a8ad049b61fe92b3db74a5319"
|
||||
integrity sha512-U+Wrbca7S8ZAxAe9L6nb6g8kPdia5hj32Puu5iOqBCMTMWFHXuK6dOV2IFrpedbTV8fjMFLdWNttQTBL6u2MRA==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
jest-util "^29.6.1"
|
||||
merge-stream "^2.0.0"
|
||||
supports-color "^8.0.0"
|
||||
|
||||
jest-worker@^29.6.3:
|
||||
jest-worker@^29.6.1, jest-worker@^29.6.3:
|
||||
version "29.6.3"
|
||||
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.6.3.tgz#7b1a47bbb6559f3c0882d16595938590e63915d5"
|
||||
integrity sha512-wacANXecZ/GbQakpf2CClrqrlwsYYDSXFd4fIGdL+dXpM2GWoJ+6bhQ7vR3TKi3+gkSfBkjy1/khH/WrYS4Q6g==
|
||||
|
||||
Reference in New Issue
Block a user