Ctrl K
ComunidadGuías…

shared/utils/urls.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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;
  }
}