// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. import * as urlSearchParams from "./url_search_params.ts"; import * as domTypes from "./dom_types.ts"; import { getRandomValues } from "./get_random_values.ts"; import { window } from "./window.ts"; import { customInspect } from "./console.ts"; interface URLParts { protocol: string; username: string; password: string; hostname: string; port: string; path: string; query: string | null; hash: string; } const patterns = { protocol: "(?:([a-z]+):)", authority: "(?://([^/?#]*))", path: "([^?#]*)", query: "(\\?[^#]*)", hash: "(#.*)", authentication: "(?:([^:]*)(?::([^@]*))?@)", hostname: "([^:]+)", port: "(?::(\\d+))" }; const urlRegExp = new RegExp( `^${patterns.protocol}?${patterns.authority}?${patterns.path}${patterns.query}?${patterns.hash}?` ); const authorityRegExp = new RegExp( `^${patterns.authentication}?${patterns.hostname}${patterns.port}?$` ); const searchParamsMethods: Array = [ "append", "delete", "set" ]; function parse(url: string): URLParts | undefined { const urlMatch = urlRegExp.exec(url); if (urlMatch) { const [, , authority] = urlMatch; const authorityMatch = authority ? authorityRegExp.exec(authority) : [null, null, null, null, null]; if (authorityMatch) { return { protocol: urlMatch[1] || "", username: authorityMatch[1] || "", password: authorityMatch[2] || "", hostname: authorityMatch[3] || "", port: authorityMatch[4] || "", path: urlMatch[3] || "", query: urlMatch[4] || "", hash: urlMatch[5] || "" }; } } return undefined; } // Based on https://github.com/kelektiv/node-uuid // TODO(kevinkassimo): Use deno_std version once possible. function generateUUID(): string { return "00000000-0000-4000-8000-000000000000".replace(/[0]/g, (): string => // random integer from 0 to 15 as a hex digit. (getRandomValues(new Uint8Array(1))[0] % 16).toString(16) ); } // Keep it outside of URL to avoid any attempts of access. export const blobURLMap = new Map(); function isAbsolutePath(path: string): boolean { return path.startsWith("/"); } // Resolves `.`s and `..`s where possible. // Preserves repeating and trailing `/`s by design. function normalizePath(path: string): string { const isAbsolute = isAbsolutePath(path); path = path.replace(/^\//, ""); const pathSegments = path.split("/"); const newPathSegments: string[] = []; for (let i = 0; i < pathSegments.length; i++) { const previous = newPathSegments[newPathSegments.length - 1]; if ( pathSegments[i] == ".." && previous != ".." && (previous != undefined || isAbsolute) ) { newPathSegments.pop(); } else if (pathSegments[i] != ".") { newPathSegments.push(pathSegments[i]); } } let newPath = newPathSegments.join("/"); if (!isAbsolute) { if (newPathSegments.length == 0) { newPath = "."; } } else { newPath = `/${newPath}`; } return newPath; } // Standard URL basing logic, applied to paths. function resolvePathFromBase(path: string, basePath: string): string { const normalizedPath = normalizePath(path); if (isAbsolutePath(normalizedPath)) { return normalizedPath; } const normalizedBasePath = normalizePath(basePath); if (!isAbsolutePath(normalizedBasePath)) { throw new TypeError("Base path must be absolute."); } // Special case. if (path == "") { return normalizedBasePath; } // Remove everything after the last `/` in `normalizedBasePath`. const prefix = normalizedBasePath.replace(/[^\/]*$/, ""); // If `normalizedPath` ends with `.` or `..`, add a trailing space. const suffix = normalizedPath.replace(/(?<=(^|\/)(\.|\.\.))$/, "/"); return normalizePath(prefix + suffix); } export class URL { private _parts: URLParts; private _searchParams!: urlSearchParams.URLSearchParams; [customInspect](): string { const keys = [ "href", "origin", "protocol", "username", "password", "host", "hostname", "port", "pathname", "hash", "search" ]; const objectString = keys .map((key: string) => `${key}: "${this[key as keyof this] || ""}"`) .join(", "); return `URL { ${objectString} }`; } private _updateSearchParams(): void { const searchParams = new urlSearchParams.URLSearchParams(this.search); for (const methodName of searchParamsMethods) { /* eslint-disable @typescript-eslint/no-explicit-any */ const method: (...args: any[]) => any = searchParams[methodName]; searchParams[methodName] = (...args: unknown[]): any => { method.apply(searchParams, args); this.search = searchParams.toString(); }; /* eslint-enable */ } this._searchParams = searchParams; // convert to `any` that has avoided the private limit // eslint-disable-next-line @typescript-eslint/no-explicit-any (this._searchParams as any).url = this; } get hash(): string { return this._parts.hash; } set hash(value: string) { value = unescape(String(value)); if (!value) { this._parts.hash = ""; } else { if (value.charAt(0) !== "#") { value = `#${value}`; } // hashes can contain % and # unescaped this._parts.hash = escape(value) .replace(/%25/g, "%") .replace(/%23/g, "#"); } } get host(): string { return `${this.hostname}${this.port ? `:${this.port}` : ""}`; } set host(value: string) { value = String(value); const url = new URL(`http://${value}`); this._parts.hostname = url.hostname; this._parts.port = url.port; } get hostname(): string { return this._parts.hostname; } set hostname(value: string) { value = String(value); this._parts.hostname = encodeURIComponent(value); } get href(): string { const authentication = this.username || this.password ? `${this.username}${this.password ? ":" + this.password : ""}@` : ""; let slash = ""; if (this.host || this.protocol === "file:") { slash = "//"; } return `${this.protocol}${slash}${authentication}${this.host}${this.pathname}${this.search}${this.hash}`; } set href(value: string) { value = String(value); if (value !== this.href) { const url = new URL(value); this._parts = { ...url._parts }; this._updateSearchParams(); } } get origin(): string { if (this.host) { return `${this.protocol}//${this.host}`; } return "null"; } get password(): string { return this._parts.password; } set password(value: string) { value = String(value); this._parts.password = encodeURIComponent(value); } get pathname(): string { return this._parts.path ? this._parts.path : "/"; } set pathname(value: string) { value = unescape(String(value)); if (!value || value.charAt(0) !== "/") { value = `/${value}`; } // paths can contain % unescaped this._parts.path = escape(value).replace(/%25/g, "%"); } get port(): string { return this._parts.port; } set port(value: string) { const port = parseInt(String(value), 10); this._parts.port = isNaN(port) ? "" : Math.max(0, port % 2 ** 16).toString(); } get protocol(): string { return `${this._parts.protocol}:`; } set protocol(value: string) { value = String(value); if (value) { if (value.charAt(value.length - 1) === ":") { value = value.slice(0, -1); } this._parts.protocol = encodeURIComponent(value); } } get search(): string { if (this._parts.query === null || this._parts.query === "") { return ""; } return this._parts.query; } set search(value: string) { value = String(value); let query: string | null; if (value === "") { query = null; } else if (value.charAt(0) !== "?") { query = `?${value}`; } else { query = value; } this._parts.query = query; this._updateSearchParams(); } get username(): string { return this._parts.username; } set username(value: string) { value = String(value); this._parts.username = encodeURIComponent(value); } get searchParams(): urlSearchParams.URLSearchParams { return this._searchParams; } constructor(url: string, base?: string | URL) { let baseParts: URLParts | undefined; if (base) { baseParts = typeof base === "string" ? parse(base) : base._parts; if (!baseParts || baseParts.protocol == "") { throw new TypeError("Invalid base URL."); } } const urlParts = parse(url); if (!urlParts) { throw new TypeError("Invalid URL."); } if (urlParts.protocol) { this._parts = urlParts; } else if (baseParts) { this._parts = { protocol: baseParts.protocol, username: baseParts.username, password: baseParts.password, hostname: baseParts.hostname, port: baseParts.port, path: resolvePathFromBase(urlParts.path, baseParts.path || "/"), query: urlParts.query, hash: urlParts.hash }; } else { throw new TypeError("URL requires a base URL."); } this._updateSearchParams(); } toString(): string { return this.href; } toJSON(): string { return this.href; } // TODO(kevinkassimo): implement MediaSource version in the future. static createObjectURL(b: domTypes.Blob): string { const origin = window.location.origin || "http://deno-opaque-origin"; const key = `blob:${origin}/${generateUUID()}`; blobURLMap.set(key, b); return key; } static revokeObjectURL(url: string): void { let urlObject; try { urlObject = new URL(url); } catch { throw new TypeError("Provided URL string is not valid"); } if (urlObject.protocol !== "blob:") { return; } // Origin match check seems irrelevant for now, unless we implement // persisten storage for per window.location.origin at some point. blobURLMap.delete(url); } }