Files
outline/shared/utils/urls.ts
T
b2b41dd3ca 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>
2026-08-02 22:44:06 -04:00

358 lines
8.7 KiB
TypeScript

import { escapeRegExp } from "es-toolkit/compat";
import env from "../env";
import { isBrowser } from "./browser";
import { parseDomain } from "./domains";
/**
* Prepends the CDN url to the given path (If a CDN is configured).
*
* @param path The path to prepend the CDN url to.
* @returns The path with the CDN url prepended.
*/
export function cdnPath(path: string): string {
return `${env.CDN_URL ?? ""}${path}`;
}
/**
* Extracts the file name from a given url.
*
* @param url The url to extract the file name from.
* @returns The file name.
*/
export function fileNameFromUrl(url: string) {
try {
const parsed = new URL(url);
return parsed.pathname.split("/").pop();
} catch (_err) {
return;
}
}
/**
* Returns true if the given string is a link to inside the application.
*
* @param url The url to check.
* @returns True if the url is internal, false otherwise.
*/
export function isInternalUrl(href: string) {
// empty strings are never internal
if (href === "") {
return false;
}
// relative paths are always internal
if (href[0] === "/") {
return true;
}
const outline = isBrowser
? parseDomain(window.location.href)
: parseDomain(env.URL);
const domain = parseDomain(href);
return (
(outline.host === domain.host && outline.port === domain.port) ||
(isBrowser &&
window.location.hostname === domain.host &&
window.location.port === domain.port)
);
}
/**
* Returns true if the given string is a link to a document.
*
* @param url The url to check.
* @returns True if a document, false otherwise.
*/
export function isDocumentUrl(url: string) {
try {
const parsed = new URL(url, env.URL);
return (
isInternalUrl(url) &&
(parsed.pathname.startsWith("/doc/") || parsed.pathname.startsWith("/d/"))
);
} catch (_err) {
return false;
}
}
/**
* Returns true if the given string is a link to a collection.
*
* @param url The url to check.
* @returns True if a collection, false otherwise.
*/
export function isCollectionUrl(url: string) {
try {
const parsed = new URL(url, env.URL);
return isInternalUrl(url) && parsed.pathname.startsWith("/collection/");
} catch (_err) {
return false;
}
}
type UrlOptions = {
/** Require the url to have a hostname. */
requireHostname?: boolean;
/** Require the url not to use HTTP, custom protocols are ok. */
requireHttps?: boolean;
/** Require the url to have a protocol. */
requireProtocol?: boolean;
};
/**
* Returns true if the given string is a url.
*
* @param text The url to check.
* @param options Parsing options.
* @returns True if a url, false otherwise.
*/
export function isUrl(
text: string,
{ requireProtocol = true, requireHostname, requireHttps }: UrlOptions = {}
) {
if (text.match(/\n/)) {
return false;
}
if (!requireProtocol && text.startsWith("www.")) {
const parts = text.split(".");
if (parts.length < 2) {
return false;
}
text = `https://${text}`;
}
try {
const url = new URL(text);
const blockedProtocols = ["javascript:", "file:", "vbscript:", "data:"];
if (blockedProtocols.includes(url.protocol)) {
return false;
}
if (requireHttps && url.protocol === "http:") {
return false;
}
if (url.hostname) {
return true;
}
return (
url.protocol !== "" &&
(url.pathname.startsWith("//") || url.pathname.startsWith("http")) &&
!requireHostname
);
} catch (_err) {
return false;
}
}
/**
* Temporary prefix applied to links in document that are not yet persisted.
*/
export const creatingUrlPrefix = "creating#";
/**
* Returns true if the given string is a link to outside the application.
*
* @param url The url to check.
* @returns True if the url is external, false otherwise.
*/
export function isExternalUrl(url: string) {
return (
!!url &&
!isInternalUrl(url) &&
!url.startsWith(creatingUrlPrefix) &&
(!env.CDN_URL || !url.startsWith(env.CDN_URL))
);
}
/**
* Returns match if the given string is a base64 encoded url.
*
* @param url The url to check.
* @returns A RegExp match if the url is base64, false otherwise.
*/
export function isBase64Url(url: string) {
const match = url.match(/^data:([a-z]+\/[^;]+);base64,(.*)/i);
return match ? match : false;
}
const allowedSchemes = [
"mailto:",
"sms:",
"fax:",
"tel:",
"geo:",
"maps:",
"magnet:",
];
const allowedImageDataUris = [
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/gif;base64,",
"data:image/webp;base64,",
"data:image/avif;base64,",
];
/**
* For use in the editor, this function will ensure that a url is
* potentially valid, and filter out unsupported and malicious protocols.
*
* @param url The url to sanitize
* @returns The sanitized href
*/
export function sanitizeUrl(url: string | null | undefined) {
if (!url) {
return undefined;
}
// 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(trimmed, { requireHostname: false }) &&
!trimmed.startsWith("/") &&
!trimmed.startsWith("#") &&
!allowedSchemes.some((scheme) => lower.startsWith(scheme))
) {
return `https://${trimmed}`;
}
return trimmed;
}
/**
* For use in the editor on image-like elements, this function will ensure
* that a src is potentially valid. In addition to the protocols allowed by
* `sanitizeUrl`, base64-encoded image data URIs are permitted (excluding
* SVG, which can contain inline scripts).
*
* @param src The src to sanitize.
* @returns The sanitized src.
*/
export function sanitizeImageSrc(src: string | null | undefined) {
if (!src) {
return undefined;
}
const lower = src.toLowerCase();
if (allowedImageDataUris.some((scheme) => lower.startsWith(scheme))) {
return src;
}
return sanitizeUrl(src);
}
/**
* Returns a regex to match the given url.
*
* @param url The url to create a regex for.
* @returns A regex to match the url.
*/
export function urlRegex(url: string | null | undefined): RegExp | undefined {
if (!url || !isUrl(url)) {
return undefined;
}
const urlObj = new URL(sanitizeUrl(url) as string);
return new RegExp(escapeRegExp(`${urlObj.protocol}//${urlObj.host}`));
}
/**
* Parse the share identifier from a given url.
*
* @param url The url to parse.
* @returns A share identifier or undefined if not found.
*/
export function parseShareIdFromUrl(url: string): string | undefined {
if (url[0] === "/") {
url = `${env.URL}${url}`;
}
let pathname;
try {
pathname = new URL(url).pathname;
} catch (_err) {
return;
}
const split = pathname.split("/");
const indexOfS = split.indexOf("s");
if (indexOfS >= 0) {
const shareId = split[indexOfS + 1];
if (shareId) {
// Remove trailing format like .md
const dotIndex = shareId.indexOf(".");
return dotIndex >= 0 ? shareId.substring(0, dotIndex) : shareId;
}
}
return undefined;
}
/**
* Extracts LIKELY urls from the given text, note this does not validate the urls.
*
* @param text The text to extract urls from.
* @returns An array of likely urls.
*/
export function getUrls(text: string) {
return Array.from(text.match(/(?:https?):\/\/[^\s]+/gi) || []);
}
/**
* Removes the fragment (hash) from a url, if present.
*
* @param url The url to modify.
* @returns The url without its fragment.
*/
export function removeUrlFragment(url: string): string {
try {
const parsed = new URL(url);
parsed.hash = "";
return parsed.toString();
} catch (_err) {
return url.split("#")[0];
}
}
/**
* Removes a suffix from the end of a url's pathname, if present. The url is
* parsed rather than string-replaced so that a matching substring elsewhere in
* the url (such as the hostname) is not affected.
*
* @param url The url to modify.
* @param suffix The pathname suffix to remove (e.g. `/edit`).
* @returns The url with the suffix removed from the end of its pathname.
*/
export function removeUrlPathSuffix(url: string, suffix: string): string {
try {
const parsed = new URL(url);
if (parsed.pathname.endsWith(suffix)) {
parsed.pathname = parsed.pathname.slice(0, -suffix.length);
}
return parsed.toString();
} catch (_err) {
return url.endsWith(suffix) ? url.slice(0, -suffix.length) : url;
}
}
/**
* Converts a url to a display friendly format, removing the protocol and trailing slash.
*
* @param url The url to convert.
* @returns The display friendly url.
*/
export function toDisplayUrl(url: string) {
try {
const parsed = new URL(url);
return parsed.host + (parsed.pathname === "/" ? "" : parsed.pathname);
} catch {
return url;
}
}