| 1 | /** |
| 2 | * Hostnames people bring for their apps: checked, spelled the one way |
| 3 | * Cloudflare and browsers agree on (lowercase, punycode), and paired with |
| 4 | * their www or apex twin. |
| 5 | * |
| 6 | * Kept free of imports so its tests run on Node as they are. |
| 7 | */ |
| 8 | |
| 9 | /** Domains a custom hostname may never be on: g1t's own. */ |
| 10 | const RESERVED = ["g1t.page", "g1t.sh", "g1t.dev"]; |
| 11 | |
| 12 | /** |
| 13 | * Public suffixes of more than one label, the common ones. A hostname |
| 14 | * directly under one of these is an apex (`shop.co.uk`), not a subdomain. |
| 15 | */ |
| 16 | const MULTI_LABEL_SUFFIXES = new Set([ |
| 17 | "co.uk", |
| 18 | "org.uk", |
| 19 | "me.uk", |
| 20 | "ltd.uk", |
| 21 | "plc.uk", |
| 22 | "net.uk", |
| 23 | "ac.uk", |
| 24 | "gov.uk", |
| 25 | "com.au", |
| 26 | "net.au", |
| 27 | "org.au", |
| 28 | "edu.au", |
| 29 | "id.au", |
| 30 | "co.nz", |
| 31 | "net.nz", |
| 32 | "org.nz", |
| 33 | "co.jp", |
| 34 | "ne.jp", |
| 35 | "or.jp", |
| 36 | "co.za", |
| 37 | "org.za", |
| 38 | "com.br", |
| 39 | "net.br", |
| 40 | "org.br", |
| 41 | "com.mx", |
| 42 | "org.mx", |
| 43 | "com.ar", |
| 44 | "com.co", |
| 45 | "com.tr", |
| 46 | "com.sg", |
| 47 | "com.hk", |
| 48 | "com.tw", |
| 49 | "com.cn", |
| 50 | "com.my", |
| 51 | "com.ph", |
| 52 | "co.in", |
| 53 | "net.in", |
| 54 | "org.in", |
| 55 | "co.id", |
| 56 | "co.il", |
| 57 | "co.kr", |
| 58 | "or.kr", |
| 59 | "com.es", |
| 60 | "com.pl", |
| 61 | "com.ua", |
| 62 | ]); |
| 63 | |
| 64 | const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/; |
| 65 | |
| 66 | export type HostnameCheck = { ok: true; hostname: string } | { ok: false; reason: string }; |
| 67 | |
| 68 | /** |
| 69 | * `input` as a hostname g1t can serve: lowercase and punycode, with no |
| 70 | * scheme, path, port or trailing dot; at least two labels, each a valid |
| 71 | * label; never a wildcard, an IP address or one of g1t's own domains. |
| 72 | */ |
| 73 | export function checkHostname(input: string): HostnameCheck { |
| 74 | let text = String(input ?? "") |
| 75 | .trim() |
| 76 | .toLowerCase(); |
| 77 | // What people paste: a URL, or a hostname with a path or a trailing dot. |
| 78 | text = text |
| 79 | .replace(/^[a-z][a-z0-9+.-]*:\/\//, "") |
| 80 | .replace(/[/?#].*$/, "") |
| 81 | .replace(/\.$/, ""); |
| 82 | if (!text) return { ok: false, reason: "Enter a domain, such as example.com or www.example.com." }; |
| 83 | if (text.includes("*")) return { ok: false, reason: "Wildcard domains are not supported yet; add each hostname on its own." }; |
| 84 | if (/:\d+$/.test(text)) return { ok: false, reason: "Leave out the port: apps are served on 443." }; |
| 85 | if (/[\s@:]/.test(text)) return { ok: false, reason: "That is not a domain." }; |
| 86 | let hostname: string; |
| 87 | try { |
| 88 | // The URL parser applies IDNA, so `bücher.example` becomes `xn--bcher-kva.example`. |
| 89 | hostname = new URL(`http://${text}/`).hostname; |
| 90 | } catch { |
| 91 | return { ok: false, reason: "That is not a domain." }; |
| 92 | } |
| 93 | if (/^\[|^\d+(\.\d+){3}$/.test(hostname)) return { ok: false, reason: "Use a domain name, not an IP address." }; |
| 94 | if (hostname.length > 253) return { ok: false, reason: "That domain is too long." }; |
| 95 | const labels = hostname.split("."); |
| 96 | if (labels.length < 2) return { ok: false, reason: "Include the domain's ending, such as .com or .io." }; |
| 97 | if (labels.some((label) => !LABEL.test(label))) { |
| 98 | return { ok: false, reason: "Each part of a domain is 1 to 63 letters, digits or hyphens, and does not start or end with a hyphen." }; |
| 99 | } |
| 100 | if (/^\d+$/.test(labels[labels.length - 1])) return { ok: false, reason: "That is not a domain." }; |
| 101 | for (const reserved of RESERVED) { |
| 102 | if (hostname === reserved || hostname.endsWith(`.${reserved}`)) { |
| 103 | return { ok: false, reason: `${reserved} is g1t's own domain; every app already has an address on it.` }; |
| 104 | } |
| 105 | } |
| 106 | return { ok: true, hostname }; |
| 107 | } |
| 108 | |
| 109 | /** The registrable domain `hostname` is under: `example.com` for `www.example.com`. */ |
| 110 | export function apexOf(hostname: string): string { |
| 111 | const labels = hostname.split("."); |
| 112 | const two = labels.slice(-2).join("."); |
| 113 | const keep = MULTI_LABEL_SUFFIXES.has(two) ? 3 : 2; |
| 114 | return labels.slice(-keep).join("."); |
| 115 | } |
| 116 | |
| 117 | /** Whether `hostname` is a registrable domain itself, which DNS cannot point with a CNAME. */ |
| 118 | export function isApex(hostname: string): boolean { |
| 119 | return apexOf(hostname) === hostname; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * The other half of a www/apex pair: `www.example.com` for `example.com` |
| 124 | * and back. Null for any other subdomain. |
| 125 | */ |
| 126 | export function twinOf(hostname: string): string | null { |
| 127 | if (isApex(hostname)) return `www.${hostname}`; |
| 128 | if (hostname.startsWith("www.") && isApex(hostname.slice(4))) return hostname.slice(4); |
| 129 | return null; |
| 130 | } |
| 131 | |
| 132 | /** What a hostname's own record is called in its DNS zone: `@` for the apex, else the part before it. */ |
| 133 | export function recordName(hostname: string): string { |
| 134 | const apex = apexOf(hostname); |
| 135 | return hostname === apex ? "@" : hostname.slice(0, -apex.length - 1); |
| 136 | } |