Files
outline/server/models/helpers/TextHelper.ts
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

131 lines
3.7 KiB
TypeScript

import { chunk, escapeRegExp } from "es-toolkit/compat";
import { AttachmentPreset } from "@shared/types";
import { isInternalUrl } from "@shared/utils/urls";
import attachmentCreator from "@server/commands/attachmentCreator";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { trace } from "@server/logging/tracing";
import type { User } from "@server/models";
import { Attachment } from "@server/models";
import FileStorage from "@server/storage/files";
import type { APIContext } from "@server/types";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import parseImages from "@server/utils/parseImages";
@trace()
export class TextHelper {
/**
* Converts attachment urls in documents to signed equivalents that allow
* direct access without a session cookie
*
* @param text The text either html or markdown which contains urls to be converted
* @param teamId The team context
* @param expiresIn The time that signed urls should expire (in seconds)
* @returns The replaced text
*/
static async attachmentsToSignedUrls(
text: string,
teamId: string,
expiresIn = 3000
) {
const attachmentIds = parseAttachmentIds(text);
await Promise.all(
attachmentIds.map(async (id) => {
const attachment = await Attachment.findOne({
where: {
id,
teamId,
},
});
if (attachment) {
const signedUrl = await FileStorage.getSignedUrl(
attachment.key,
expiresIn
);
text = text.replace(
new RegExp(escapeRegExp(attachment.redirectUrl), "g"),
signedUrl
);
}
})
);
return text;
}
/**
* Replaces remote and base64 encoded images in the given text with attachment
* urls and uploads the images to the storage provider.
*
* @param ctx The API context
* @param markdown The text to replace the images in
* @param user The user context
* @returns The text with the images replaced
*/
static async replaceImagesWithAttachments(
ctx: APIContext,
markdown: string,
user: User,
options: {
/** If true, only process base64 encoded images */
base64Only?: boolean;
} = {}
) {
let output = markdown;
const images = parseImages(markdown);
const timeoutPerImage = Math.floor(
Math.min(env.REQUEST_TIMEOUT / images.length, 10000)
);
const chunks = chunk(images, 10);
for (const chunk of chunks) {
await Promise.all(
chunk.map(async (image) => {
// Skip attempting to fetch images that are not valid urls
try {
new URL(image.src);
} catch (_e) {
return;
}
if (isInternalUrl(image.src)) {
return;
}
if (options.base64Only && !image.src.startsWith("data:")) {
return;
}
try {
const attachment = await attachmentCreator({
name: image.alt ?? "image",
url: image.src,
preset: AttachmentPreset.DocumentAttachment,
user,
fetchOptions: {
timeout: timeoutPerImage,
},
ctx,
});
if (attachment) {
output = output.replace(
new RegExp(escapeRegExp(image.src), "g"),
attachment.redirectUrl
);
}
} catch (err) {
Logger.warn("Failed to download image for attachment", {
error: err.message,
src: image.src,
});
}
})
);
}
return output;
}
}