diff --git a/server/commands/documentDuplicator.test.ts b/server/commands/documentDuplicator.test.ts index 69db89dfba..f04698a070 100644 --- a/server/commands/documentDuplicator.test.ts +++ b/server/commands/documentDuplicator.test.ts @@ -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({ diff --git a/server/commands/documentDuplicator.ts b/server/commands/documentDuplicator.ts index ba47943b13..a3a5546e55 100644 --- a/server/commands/documentDuplicator.ts +++ b/server/commands/documentDuplicator.ts @@ -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 { - 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 { + 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 { + const newDocuments: Document[] = []; + const references = new Map(); + const originalCollections = new Map(); - 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 { + 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; diff --git a/server/models/helpers/ProseMirrorHelper.test.ts b/server/models/helpers/ProseMirrorHelper.test.ts index d3c6b2fae2..2aa114fdff 100644 --- a/server/models/helpers/ProseMirrorHelper.test.ts +++ b/server/models/helpers/ProseMirrorHelper.test.ts @@ -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([ diff --git a/server/models/helpers/ProsemirrorHelper.tsx b/server/models/helpers/ProsemirrorHelper.tsx index e3b9d748fc..1c3d4e7081 100644 --- a/server/models/helpers/ProsemirrorHelper.tsx +++ b/server/models/helpers/ProsemirrorHelper.tsx @@ -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 + ): 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 `-` 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 diff --git a/server/queues/tasks/DuplicateCollectionDocumentsTask.test.ts b/server/queues/tasks/DuplicateCollectionDocumentsTask.test.ts index 455bd2f025..3c33ceb010 100644 --- a/server/queues/tasks/DuplicateCollectionDocumentsTask.test.ts +++ b/server/queues/tasks/DuplicateCollectionDocumentsTask.test.ts @@ -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({ diff --git a/server/queues/tasks/DuplicateCollectionDocumentsTask.ts b/server/queues/tasks/DuplicateCollectionDocumentsTask.ts index a71f3bd559..b3b27bae2d 100644 --- a/server/queues/tasks/DuplicateCollectionDocumentsTask.ts +++ b/server/queues/tasks/DuplicateCollectionDocumentsTask.ts @@ -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 { 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, + }); }); } } diff --git a/shared/utils/urls.test.ts b/shared/utils/urls.test.ts index 74f97c6a74..ea9e5cbf53 100644 --- a/shared/utils/urls.test.ts +++ b/shared/utils/urls.test.ts @@ -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"); diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index 4fa098b403..092cf2cb35 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -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; } /**