shared/utils/urls.ts
Rama main · réplica de lectura del repositorio original.
import { escapeRegExp } from "es-toolkit/compat";
import isUUID from "validator/lib/isUUID";
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;
}
}
/**
* Converts a signed attachment url from this instance, which expires, to the
* stable attachment redirect url. The signature is not verified.
*
* @param url The url to convert.
* @returns The attachment redirect url, or undefined if the url is not a signed
* attachment url.
*/
export function signedUrlToAttachmentUrl(url: string): string | undefined {
try {
const parsed = new URL(url, isBrowser ? window.location.href : env.URL);
const sig = parsed.searchParams.get("sig");
if (
!isInternalUrl(parsed.href) ||
parsed.pathname !== "/api/files.get" ||
!sig
) {
return;
}
const payload = JSON.parse(
atob(sig.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
);
if (payload?.type !== "attachment" || typeof payload.key !== "string") {
return;
}
// Attachment keys are in the format bucket/userId/id/name.
const id = payload.key.split("/")[2];
return isUUID(id) ? `/api/attachments.redirect?id=${id}` : undefined;
} catch (_err) {
return;
}
}
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;
}
}
/**
* Schemes that are never treated as private-use, either because they are web
* schemes validated elsewhere or because they can execute or embed content.
*/
const nonPrivateUseSchemes = new Set([
"http",
"https",
"ftp",
"ws",
"wss",
"file",
"data",
"javascript",
"vbscript",
"blob",
"about",
]);
/**
* Returns true if the given url uses a private-use URI scheme, as registered
* by native apps to receive OAuth redirects (RFC 8252 section 7.1), for
* example `com.example.app:/oauth2redirect` or `myapp://callback`.
*
* @param text The url to check.
* @returns True if the url uses a private-use URI scheme, false otherwise.
*/
export function isPrivateUseSchemeUrl(text: string) {
if (/\s/.test(text)) {
return false;
}
let url: URL;
try {
url = new URL(text);
} catch (_err) {
return false;
}
const scheme = url.protocol.slice(0, -1).toLowerCase();
if (nonPrivateUseSchemes.has(scheme)) {
return false;
}
// Require a destination after the scheme, e.g. reject a bare "myapp:".
return !!url.host || (url.pathname !== "" && url.pathname !== "/");
}
/**
* Check if a URI is a loopback address (localhost, 127.0.0.1, or [::1]).
*
* @param uri - the redirect URI to check.
* @returns true if the URI targets a loopback address.
*/
export function isLoopbackUri(uri: string | undefined): boolean {
if (!uri) {
return false;
}
try {
const url = new URL(uri);
return (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]"
);
} catch {
return false;
}
}
/**
* Returns the hostname of a URL. Internationalized hostnames are returned in
* their punycode form.
*
* @param url the URL to parse.
* @returns the hostname, or undefined if the URL cannot be parsed.
*/
export function getHostname(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
/**
* 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);
}
const defaultPorts: Record<string, string> = {
"http:": "80",
"https:": "443",
};
/**
* Returns a regex to match urls that begin with the given url's origin.
*
* @param url The url to create a regex for.
* @returns A regex anchored to the origin, a path and query may follow.
*/
export function urlRegex(url: string | null | undefined): RegExp | undefined {
if (!url || !isUrl(url)) {
return undefined;
}
const urlObj = new URL(sanitizeUrl(url) as string);
const origin = escapeRegExp(`${urlObj.protocol}//${urlObj.host}`);
// URL.host omits a default port, but the matched url can still state it.
const defaultPort = urlObj.port ? undefined : defaultPorts[urlObj.protocol];
const port = defaultPort ? `(?::${defaultPort})?` : "";
return new RegExp(`^${origin}${port}(?=[/?#]|$)`);
}
/**
* 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) || []);
}
/**
* Adds the port that the application is publicly reachable on to a url that
* does not specify one. A proxy commonly forwards a Host header without the
* public port, so urls derived from an incoming request would otherwise point
* at the wrong address.
*
* @param url the url to modify, may be relative.
* @returns the url with the port added, if one was missing.
*/
export function addMissingUrlPort(url: string): string {
let port;
try {
port = new URL(env.URL).port;
} catch (_err) {
return url;
}
if (!port) {
return url;
}
try {
const parsed = new URL(url);
if (parsed.port) {
return url;
}
parsed.port = port;
return parsed.toString();
} catch (_err) {
// Relative urls are resolved against the current origin and need no port.
return url;
}
}
/**
* 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;
}
}