fix: Remap internal links when duplicating a document tree (#13270)

* fix: Remap internal links when duplicating a document tree

Duplicating a document tree now assigns the identifiers of every duplicate
before any content is written, so links and mentions between the documents
being duplicated point at the copies rather than the originals.

* fix: Remap fully qualified links when duplicating a document tree

Links written as a full url to this installation are now remapped alongside
relative ones, and stay fully qualified. Where a link is displayed as its own
url the text is updated to match.

Also trims surrounding whitespace in sanitizeUrl, which otherwise failed
validation and prepended a second scheme to an already qualified url.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: Remap links across trees when duplicating a collection

The collection duplication task duplicated each root document separately, so
only links within a single tree were remapped. It now duplicates the whole
collection as one unit, with identifiers assigned across every tree before any
content is written.

* fix: Match document links by identifier rather than host when duplicating

An installation can be reached through more than one host, so comparing a
link's host against the configured url left fully qualified links to a
document in the duplicated set unmapped. The document a link identifies now
decides whether it is replaced, and the host it was written with is kept.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-08-02 22:44:06 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 000f2f98fb
commit b2b41dd3ca
8 changed files with 705 additions and 67 deletions
+190
View File
@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { MentionType } from "@shared/types";
import { createContext } from "@server/context";
import env from "@server/env";
import { sequelize } from "@server/storage/database";
import {
buildCollection,
@@ -6,6 +9,7 @@ import {
buildUser,
} from "@server/test/factories";
import { withAPIContext } from "@server/test/support";
import { generateUrlId } from "@server/utils/url";
import documentDuplicator from "./documentDuplicator";
describe("documentDuplicator", () => {
@@ -201,6 +205,192 @@ describe("documentDuplicator", () => {
expect(duplicatedChild?.sourceMetadata?.fileName).toEqual("child.md");
});
it("should remap links between documents in the duplicated tree", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
title: "parent",
});
const child2 = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
parentDocumentId: original.id,
title: "child 2",
});
const child1 = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
parentDocumentId: original.id,
title: "child 1",
text: `See [child 2](${child2.path}) and the [parent](/doc/${original.id}).`,
});
const response = await withAPIContext(user, (ctx) =>
documentDuplicator(ctx, {
document: original,
collection,
recursive: true,
})
);
const duplicatedParent = response.find(
(doc) => doc.sourceMetadata?.originalDocumentId === original.id
);
const duplicatedChild1 = response.find(
(doc) => doc.sourceMetadata?.originalDocumentId === child1.id
);
const duplicatedChild2 = response.find(
(doc) => doc.sourceMetadata?.originalDocumentId === child2.id
);
expect(duplicatedChild1!.text).toContain(duplicatedChild2!.path);
expect(duplicatedChild1!.text).toContain(duplicatedParent!.path);
expect(duplicatedChild1!.text).not.toContain(child2.urlId);
expect(duplicatedChild1!.text).not.toContain(original.id);
});
it("should remap mentions of documents in the duplicated tree", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
title: "parent",
});
const child2 = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
parentDocumentId: original.id,
title: "child 2",
});
const child1 = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
parentDocumentId: original.id,
title: "child 1",
content: {
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "mention",
attrs: {
type: MentionType.Document,
modelId: child2.id,
label: "child 2",
actorId: user.id,
id: randomUUID(),
},
},
],
},
],
},
});
const response = await withAPIContext(user, (ctx) =>
documentDuplicator(ctx, {
document: original,
collection,
recursive: true,
})
);
const duplicatedChild1 = response.find(
(doc) => doc.sourceMetadata?.originalDocumentId === child1.id
);
const duplicatedChild2 = response.find(
(doc) => doc.sourceMetadata?.originalDocumentId === child2.id
);
const mention = duplicatedChild1!.content!.content![0].content![0];
expect(mention.attrs!.modelId).toEqual(duplicatedChild2!.id);
});
it("should not remap links to documents outside of the duplicated tree", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const other = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
title: "other",
});
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
title: "parent",
text: `See [other](${other.path}) and [elsewhere](https://example.com/doc/${other.urlId}).`,
});
const response = await withAPIContext(user, (ctx) =>
documentDuplicator(ctx, {
document: original,
collection,
recursive: true,
})
);
expect(response[0].text).toContain(other.path);
expect(response[0].text).toContain(
`https://example.com/doc/${other.urlId}`
);
});
it("should remap self links when duplicating a single document", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
});
const urlId = generateUrlId();
const original = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
title: "parent",
urlId,
text: [
`Relative [top](/doc/parent-${urlId}).`,
`Qualified [top](${env.URL}/doc/parent-${urlId}).`,
`Plain <${env.URL}/doc/parent-${urlId}>`,
].join("\n\n"),
});
const response = await withAPIContext(user, (ctx) =>
documentDuplicator(ctx, {
document: original,
collection,
title: "parent (copy)",
})
);
expect(response[0].text).toContain(`(${response[0].path})`);
expect(response[0].text).toContain(`(${env.URL}${response[0].path})`);
expect(response[0].text).toContain(`<${env.URL}${response[0].path}>`);
expect(response[0].text).not.toContain(original.urlId);
});
it("should copy fullWidth property when duplicating document", async () => {
const user = await buildUser();
const original = await buildDocument({
+193 -53
View File
@@ -1,9 +1,11 @@
import { randomUUID } from "node:crypto";
import { Op } from "sequelize";
import type { Document } from "@server/models";
import { Collection } from "@server/models";
import { Collection, Document } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import type { DocumentReference } from "@server/models/helpers/ProsemirrorHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import type { APIContext } from "@server/types";
import { generateUrlId } from "@server/utils/url";
import documentCreator from "./documentCreator";
type Props = {
@@ -21,47 +23,147 @@ type Props = {
recursive?: boolean;
};
type ManyProps = {
/** The documents to duplicate, in the order they should be created */
documents: Document[];
/** The collection to add the duplicated documents to */
collection?: Collection | null;
/** Override of the duplicated documents publish state */
publish?: boolean;
/** Whether to duplicate child documents */
recursive?: boolean;
};
/** A document to duplicate, and where its duplicate should be placed. */
type Root = {
/** The document to duplicate */
document: Document;
/** Override of the duplicated document title */
title?: string;
/** Override of the parent document to add the duplicate to */
parentDocumentId?: string;
};
/** A document to duplicate, with the identifiers assigned to its duplicate. */
type DuplicateItem = {
/** The document being duplicated */
original: Document;
/** The id assigned to the duplicate */
id: string;
/** The url identifier assigned to the duplicate */
urlId: string;
/** The title of the duplicate */
title: string;
/** The children to duplicate under the duplicate */
children: DuplicateItem[];
};
/**
* Duplicates a document, optionally including its child documents. Links
* between the duplicated documents are remapped to point at the copies.
*
* @param ctx the API context containing the acting user and transaction.
* @param props the document to duplicate and optional overrides.
* @returns the duplicated documents.
*/
export default async function documentDuplicator(
ctx: APIContext,
{ document, collection, parentDocumentId, title, publish, recursive }: Props
): Promise<Document[]> {
const newDocuments: Document[] = [];
const sharedProperties = {
collectionId: collection?.id,
publish: publish ?? !!document.publishedAt,
};
const duplicated = await documentCreator(ctx, {
parentDocumentId,
icon: document.icon,
color: document.color,
fullWidth: document.fullWidth,
title: title ?? document.title,
content: ProsemirrorHelper.removeMarks(
DocumentHelper.toProsemirror(document),
["comment"]
),
sourceMetadata: {
...document.sourceMetadata,
originalDocumentId: document.id,
},
...sharedProperties,
return duplicateRoots(ctx, {
roots: [{ document, title, parentDocumentId }],
collection,
publish,
recursive,
});
}
duplicated.collection = collection ?? null;
newDocuments.push(duplicated);
/**
* Duplicates several documents as one unit, so that links between them are
* remapped to the copies no matter which of the documents they cross between.
*
* @param ctx the API context containing the acting user and transaction.
* @param props the documents to duplicate and optional overrides.
* @returns the duplicated documents.
*/
export async function documentsDuplicator(
ctx: APIContext,
{ documents, collection, publish, recursive }: ManyProps
): Promise<Document[]> {
return duplicateRoots(ctx, {
roots: documents.map((document) => ({ document })),
collection,
publish,
recursive,
});
}
const originalCollection = document?.collectionId
? await Collection.findByPk(document.collectionId, {
attributes: {
include: ["documentStructure"],
},
})
: null;
async function duplicateRoots(
ctx: APIContext,
{
roots,
collection,
publish,
recursive,
}: {
roots: Root[];
collection?: Collection | null;
publish?: boolean;
recursive?: boolean;
}
): Promise<Document[]> {
const newDocuments: Document[] = [];
const references = new Map<string, DocumentReference>();
const originalCollections = new Map<string, Collection | null>();
async function duplicateChildDocuments(
async function originalCollectionFor(original: Document) {
const collectionId = original.collectionId;
if (!collectionId) {
return null;
}
if (!originalCollections.has(collectionId)) {
originalCollections.set(
collectionId,
await Collection.findByPk(collectionId, {
attributes: {
include: ["documentStructure"],
},
})
);
}
return originalCollections.get(collectionId) ?? null;
}
async function buildItem(
original: Document,
duplicatedDocument: Document
originalCollection: Collection | null,
titleOverride?: string
): Promise<DuplicateItem> {
const id = randomUUID();
const urlId = generateUrlId();
const itemTitle = titleOverride ?? original.title;
const reference = {
id,
path: Document.getPath({ title: itemTitle, urlId }),
};
references.set(original.id, reference);
references.set(original.urlId, reference);
return {
original,
id,
urlId,
title: itemTitle,
children: recursive
? await buildChildItems(original, originalCollection)
: [],
};
}
async function buildChildItems(
original: Document,
originalCollection: Collection | null
) {
const childDocuments = await original.findChildDocuments(
{
@@ -81,32 +183,70 @@ export default async function documentDuplicator(
originalCollection?.getDocumentTree(original.id)?.children ?? []
).reverse(); // we have to reverse since the child documents will be added in reverse order
const items: DuplicateItem[] = [];
for (const childDocument of sorted) {
const duplicatedChildDocument = await documentCreator(ctx, {
parentDocumentId: duplicatedDocument.id,
icon: childDocument.icon,
color: childDocument.color,
fullWidth: childDocument.fullWidth,
title: childDocument.title,
content: ProsemirrorHelper.removeMarks(
DocumentHelper.toProsemirror(childDocument),
items.push(await buildItem(childDocument, originalCollection));
}
return items;
}
async function duplicateItem(
item: DuplicateItem,
options: { parentDocumentId?: string; publish: boolean }
) {
const duplicated = await documentCreator(ctx, {
id: item.id,
urlId: item.urlId,
parentDocumentId: options.parentDocumentId,
publish: options.publish,
collectionId: collection?.id,
icon: item.original.icon,
color: item.original.color,
fullWidth: item.original.fullWidth,
title: item.title,
content: ProsemirrorHelper.replaceDocumentReferences(
ProsemirrorHelper.removeMarks(
DocumentHelper.toProsemirror(item.original),
["comment"]
),
sourceMetadata: {
...childDocument.sourceMetadata,
originalDocumentId: childDocument.id,
},
...sharedProperties,
});
references
),
sourceMetadata: {
...item.original.sourceMetadata,
originalDocumentId: item.original.id,
},
});
duplicatedChildDocument.collection = collection ?? null;
newDocuments.push(duplicatedChildDocument);
await duplicateChildDocuments(childDocument, duplicatedChildDocument);
duplicated.collection = collection ?? null;
newDocuments.push(duplicated);
for (const child of item.children) {
await duplicateItem(child, {
...options,
parentDocumentId: duplicated.id,
});
}
}
if (recursive) {
await duplicateChildDocuments(document, duplicated);
// The identifiers of every duplicate are assigned before any content is
// written so that links between the documents being duplicated can be
// remapped to the copies, leaving the duplicate self-contained.
const items: DuplicateItem[] = [];
for (const root of roots) {
items.push(
await buildItem(
root.document,
await originalCollectionFor(root.document),
root.title
)
);
}
for (const [index, item] of items.entries()) {
await duplicateItem(item, {
parentDocumentId: roots[index].parentDocumentId,
publish: publish ?? !!roots[index].document.publishedAt,
});
}
return newDocuments;
@@ -8,6 +8,7 @@ import { MentionType } from "@shared/types";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { createContext } from "@server/context";
import { schema } from "@server/editor";
import env from "@server/env";
import { Attachment } from "@server/models";
import { buildProseMirrorDoc, buildUser } from "@server/test/factories";
import type { MentionAttrs } from "./ProsemirrorHelper";
@@ -975,6 +976,157 @@ describe("ProsemirrorHelper", () => {
});
});
describe("replaceDocumentReferences", () => {
const replacement = {
id: "ca3a20ba-0eab-4b04-b45c-b9d0e9d6d3f0",
path: "/doc/copy-of-a-document-hLpJHTvIRW",
};
const references = new Map([
["7a0e9dbc-1de3-4dd7-b1a3-1a5b1e5ecd2e", replacement],
["oCB0mUOc5f", replacement],
]);
const linkedParagraph = (href: string) => ({
type: "paragraph",
content: [
{
type: "text",
text: "A link",
marks: [{ type: "link", attrs: { href } }],
},
],
});
const mentionParagraph = (type: MentionType, modelId: string) => ({
type: "paragraph",
content: [
{
type: "mention",
attrs: {
type,
modelId,
label: "A mention",
id: "d4f6a3ee-0d59-4e2e-b1a8-2f0c5f6b3f8d",
},
},
],
});
const hrefAfterReplace = (href: string) => {
const result = ProsemirrorHelper.replaceDocumentReferences(
buildProseMirrorDoc([linkedParagraph(href)]),
references
);
return result.content![0].content![0].marks![0].attrs!.href;
};
const modelIdAfterReplace = (type: MentionType, modelId: string) => {
const result = ProsemirrorHelper.replaceDocumentReferences(
buildProseMirrorDoc([mentionParagraph(type, modelId)]),
references
);
return result.content![0].content![0].attrs!.modelId;
};
it("should replace a link to a document by slug", () => {
expect(hrefAfterReplace("/doc/a-document-oCB0mUOc5f")).toBe(
replacement.path
);
});
it("should replace a link to a document by id", () => {
expect(
hrefAfterReplace("/doc/7a0e9dbc-1de3-4dd7-b1a3-1a5b1e5ecd2e")
).toBe(replacement.path);
});
it("should retain the hash and query of a replaced link", () => {
expect(hrefAfterReplace("/doc/a-document-oCB0mUOc5f#heading")).toBe(
`${replacement.path}#heading`
);
expect(hrefAfterReplace("/doc/a-document-oCB0mUOc5f?foo=bar")).toBe(
`${replacement.path}?foo=bar`
);
});
it("should replace a fully qualified link, keeping it fully qualified", () => {
expect(hrefAfterReplace(`${env.URL}/doc/a-document-oCB0mUOc5f`)).toBe(
`${env.URL}${replacement.path}`
);
expect(
hrefAfterReplace(`${env.URL}/doc/a-document-oCB0mUOc5f#heading`)
).toBe(`${env.URL}${replacement.path}#heading`);
});
it("should replace a fully qualified link written with another host", () => {
expect(
hrefAfterReplace("https://wiki.example.com/doc/a-document-oCB0mUOc5f")
).toBe(`https://wiki.example.com${replacement.path}`);
expect(
hrefAfterReplace("http://localhost:3000/doc/a-document-oCB0mUOc5f")
).toBe(`http://localhost:3000${replacement.path}`);
});
it("should replace the text of a link that is displayed as its url", () => {
const href = "/doc/a-document-oCB0mUOc5f";
const result = ProsemirrorHelper.replaceDocumentReferences(
buildProseMirrorDoc([
{
type: "paragraph",
content: [
{
type: "text",
text: href,
marks: [{ type: "link", attrs: { href } }],
},
],
},
]),
references
);
const text = result.content![0].content![0];
expect(text.text).toBe(replacement.path);
expect(text.marks![0].attrs!.href).toBe(replacement.path);
});
it("should not replace links to other documents", () => {
const relative = "/doc/another-document-Iz6qBGZQIU";
expect(hrefAfterReplace(relative)).toBe(relative);
const qualified = `${env.URL}/doc/another-document-Iz6qBGZQIU`;
expect(hrefAfterReplace(qualified)).toBe(qualified);
});
it("should not replace links that are not to a document", () => {
const href = "/search?query=oCB0mUOc5f";
expect(hrefAfterReplace(href)).toBe(href);
});
it("should not replace links with an unsupported protocol", () => {
const href = "mailto:oCB0mUOc5f@example.com";
expect(hrefAfterReplace(href)).toBe(href);
});
it("should replace the model of a document mention", () => {
expect(
modelIdAfterReplace(
MentionType.Document,
"7a0e9dbc-1de3-4dd7-b1a3-1a5b1e5ecd2e"
)
).toBe(replacement.id);
});
it("should not replace the model of a user mention", () => {
expect(
modelIdAfterReplace(
MentionType.User,
"7a0e9dbc-1de3-4dd7-b1a3-1a5b1e5ecd2e"
)
).toBe("7a0e9dbc-1de3-4dd7-b1a3-1a5b1e5ecd2e");
});
});
describe("removeFirstHeading", () => {
it("should remove an H1 that is the first child", () => {
const doc = buildProseMirrorDoc([
@@ -34,6 +34,7 @@ import {
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isInternalUrl } from "@shared/utils/urls";
import attachmentCreator from "@server/commands/attachmentCreator";
import { plugins, schema, parser } from "@server/editor";
@@ -65,6 +66,14 @@ export type HTMLOptions = {
cspNonce?: string;
};
/** The identifiers of a document that another document's content may link to. */
export type DocumentReference = {
/** The id of the document */
id: string;
/** The path to the document */
path: string;
};
export type MentionAttrs = {
type: MentionType;
label: string;
@@ -326,6 +335,88 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
return doc.copy(Fragment.fromArray([node]));
}
/**
* Replaces links and mentions that point to the given documents so that they
* point to their replacements instead. A link is matched on the document it
* identifies rather than its host, as an installation can be reached through
* more than one, and a fully qualified link keeps the host it was written
* with.
*
* @param doc The prosemirror document or JSON data.
* @param documents A map keyed by both the id and urlId of each document to
* replace, with the id and path of its replacement.
* @returns The document data with references replaced.
*/
static replaceDocumentReferences(
doc: Node | ProsemirrorData,
documents: Map<string, DocumentReference>
): ProsemirrorData {
const json = "toJSON" in doc ? (doc.toJSON() as ProsemirrorData) : doc;
function replaceHref(href: string) {
let origin = "";
let path = href;
if (!href.startsWith("/")) {
try {
const url = new URL(href);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return href;
}
origin = url.origin;
path = `${url.pathname}${url.search}${url.hash}`;
} catch (_err) {
return href;
}
}
const match = /^\/doc\/([^/?#]+)(.*)$/.exec(path);
if (!match) {
return href;
}
// The identifier is either a document id or a `<slug>-<urlId>` pair.
const slug = UrlHelper.SLUG_URL_REGEX.exec(match[1]);
const replacement =
documents.get(match[1]) ?? (slug ? documents.get(slug[1]) : undefined);
return replacement ? `${origin}${replacement.path}${match[2]}` : href;
}
function replaceDocumentReferencesInner(node: ProsemirrorData) {
if (
node.type === "mention" &&
node.attrs?.type === MentionType.Document &&
typeof node.attrs.modelId === "string"
) {
const replacement = documents.get(node.attrs.modelId);
if (replacement) {
node.attrs.modelId = replacement.id;
}
}
node.marks?.forEach((mark) => {
if (mark.type === "link" && typeof mark.attrs?.href === "string") {
const href = replaceHref(mark.attrs.href);
// A link that has the url as its own text is displayed as the url,
// so the two are kept in sync.
if (node.text === mark.attrs.href) {
node.text = href;
}
mark.attrs.href = href;
}
});
node.content?.forEach(replaceDocumentReferencesInner);
return node;
}
return replaceDocumentReferencesInner(json);
}
static async replaceInternalUrls(
doc: Node | ProsemirrorData,
basePath: string
@@ -51,6 +51,59 @@ describe("DuplicateCollectionDocumentsTask", () => {
expect(duplicatedChild?.parentDocumentId).toEqual(duplicatedParent?.id);
});
it("should remap links between documents in different trees", async () => {
const user = await buildUser();
const original = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
const second = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
title: "second",
});
const first = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: original.id,
title: "first",
text: [
`Relative [second](${second.path}).`,
`Qualified [second](https://wiki.example.com${second.path}).`,
].join("\n\n"),
});
await new DuplicateCollectionDocumentsTask().perform({
collectionId: collection.id,
originalCollectionId: original.id,
actorId: user.id,
ip: null,
});
const documents = await Document.findAll({
where: {
collectionId: collection.id,
},
});
const duplicatedFirst = documents.find(
(d) => d.sourceMetadata?.originalDocumentId === first.id
);
const duplicatedSecond = documents.find(
(d) => d.sourceMetadata?.originalDocumentId === second.id
);
expect(duplicatedFirst?.text).toContain(`(${duplicatedSecond?.path})`);
expect(duplicatedFirst?.text).toContain(
`(https://wiki.example.com${duplicatedSecond?.path})`
);
expect(duplicatedFirst?.text).not.toContain(second.urlId);
});
it("should not duplicate drafts", async () => {
const user = await buildUser();
const original = await buildCollection({
@@ -1,5 +1,5 @@
import { Op } from "sequelize";
import documentDuplicator from "@server/commands/documentDuplicator";
import { documentsDuplicator } from "@server/commands/documentDuplicator";
import { createContext } from "@server/context";
import { Collection, Document, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -59,13 +59,13 @@ export default class DuplicateCollectionDocumentsTask extends BaseTask<Props> {
transaction,
});
for (const document of sorted) {
await documentDuplicator(ctx, {
document,
collection,
recursive: true,
});
}
// The whole collection is duplicated as one unit so that links between
// documents in different trees are remapped to the copies.
await documentsDuplicator(ctx, {
documents: sorted,
collection,
recursive: true,
});
});
}
}
+9
View File
@@ -140,6 +140,15 @@ describe("sanitizeUrl", () => {
);
});
it("should trim surrounding whitespace rather than append a scheme", () => {
expect(urlsUtils.sanitizeUrl("https://www.google.com\n")).toEqual(
"https://www.google.com"
);
expect(urlsUtils.sanitizeUrl(" https://www.google.com ")).toEqual(
"https://www.google.com"
);
});
describe("special urls", () => {
it("should return the url as it's if starting with /", () => {
expect(urlsUtils.sanitizeUrl("/drafts")).toEqual("/drafts");
+9 -6
View File
@@ -209,16 +209,19 @@ export function sanitizeUrl(url: string | null | undefined) {
return undefined;
}
const lower = url.toLowerCase();
// Surrounding whitespace, a newline in particular, would otherwise fail
// validation and have a scheme prepended to an already qualified url.
const trimmed = url.trim();
const lower = trimmed.toLowerCase();
if (
!isUrl(url, { requireHostname: false }) &&
!url.startsWith("/") &&
!url.startsWith("#") &&
!isUrl(trimmed, { requireHostname: false }) &&
!trimmed.startsWith("/") &&
!trimmed.startsWith("#") &&
!allowedSchemes.some((scheme) => lower.startsWith(scheme))
) {
return `https://${url}`;
return `https://${trimmed}`;
}
return url;
return trimmed;
}
/**