| 1 | /** |
| 2 | * Screenshots of a project's production: one per deploy, kept in R2 under |
| 3 | * the app's g1t.page hostname, with the commit it shows. An app on |
| 4 | * g1t.page is visited only by hostname: a custom domain serves the same |
| 5 | * app. A project deployed elsewhere is visited at its production address, |
| 6 | * when that is a public one (below). |
| 7 | */ |
| 8 | |
| 9 | export const DEPLOYMENTS_DOMAIN = "g1t.page"; |
| 10 | /** The page is drawn at this size; the overview shows it scaled down. */ |
| 11 | export const VIEWPORT = { width: 1280, height: 800 }; |
| 12 | /** How long a page has to settle before it is taken anyway. */ |
| 13 | export const SETTLE_MS = 12_000; |
| 14 | /** A screenshot that could not be taken is tried again after this long. */ |
| 15 | export const RETRY_AFTER_MS = 5 * 60 * 1000; |
| 16 | |
| 17 | /** |
| 18 | * An app's production at `commit`. `since` is when it was last deployed: |
| 19 | * a resumed or moved app is deployed again at the same commit, and a |
| 20 | * screenshot from before that (of a page saying it was paused, say) is not |
| 21 | * current. |
| 22 | */ |
| 23 | export type ShotRequest = { host: string; commit: string; since?: string }; |
| 24 | |
| 25 | /** Kept screenshots and attempt notes older than this are deleted; a page asking for one again takes it anew. */ |
| 26 | export const KEEP_DAYS = 30; |
| 27 | |
| 28 | /** Hostnames on g1t.page that are not apps. */ |
| 29 | const RESERVED = new Set(["domains", "www"]); |
| 30 | |
| 31 | /** The request, if it names an app on g1t.page and a commit. */ |
| 32 | export function parseShot(input: unknown): ShotRequest | null { |
| 33 | if (!input || typeof input !== "object") return null; |
| 34 | const { host, commit, since } = input as Record<string, unknown>; |
| 35 | if (typeof host !== "string" || typeof commit !== "string") return null; |
| 36 | const name = host.toLowerCase(); |
| 37 | const suffix = `.${DEPLOYMENTS_DOMAIN}`; |
| 38 | if (!name.endsWith(suffix)) return null; |
| 39 | const label = name.slice(0, -suffix.length); |
| 40 | if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || RESERVED.has(label)) return null; |
| 41 | if (!/^[0-9a-f]{7,64}$/i.test(commit)) return null; |
| 42 | const shot: ShotRequest = { host: name, commit: commit.toLowerCase() }; |
| 43 | if (typeof since === "string" && Number.isFinite(Date.parse(since))) shot.since = since; |
| 44 | return shot; |
| 45 | } |
| 46 | |
| 47 | /** Where an app's screenshot is kept. */ |
| 48 | export function shotKey(host: string): string { |
| 49 | return `production/${host}.jpg`; |
| 50 | } |
| 51 | |
| 52 | /** Where the last attempt at an app's screenshot is noted. */ |
| 53 | export function attemptKey(host: string): string { |
| 54 | return `attempts/${host}`; |
| 55 | } |
| 56 | |
| 57 | /** Whether a kept screenshot shows the request: its commit, taken after the deploy. */ |
| 58 | export function isCurrent(kept: { commit: string; capturedAt: string }, request: ShotRequest): boolean { |
| 59 | if (kept.commit !== request.commit) return false; |
| 60 | if (!request.since) return true; |
| 61 | return Date.parse(kept.capturedAt) >= Date.parse(request.since); |
| 62 | } |
| 63 | |
| 64 | /** Whether a kept object is old enough to delete. */ |
| 65 | export function expired(uploaded: Date, now: number, days = KEEP_DAYS): boolean { |
| 66 | return now - uploaded.getTime() > days * 24 * 60 * 60 * 1000; |
| 67 | } |
| 68 | |
| 69 | /** Whether to try again: not for the same commit within `RETRY_AFTER_MS`. */ |
| 70 | export function shouldAttempt(last: { commit?: string; at?: string } | null | undefined, commit: string, now: number): boolean { |
| 71 | if (!last?.at || last.commit !== commit) return true; |
| 72 | const at = Date.parse(last.at); |
| 73 | return !Number.isFinite(at) || now - at >= RETRY_AFTER_MS; |
| 74 | } |
| 75 | |
| 76 | /* |
| 77 | * Production deployed elsewhere: a project that runs on its own pipeline, |
| 78 | * at the address it was given (or the one its reported production |
| 79 | * deployment names). Its screenshot is kept apart from g1t.page apps', |
| 80 | * under `external/<host>.jpg`, by the address's host. With no g1t deploy |
| 81 | * to say when it changed, it is taken when the address is set or changed, |
| 82 | * when a production deployment is reported for it, and otherwise at most |
| 83 | * once a day (index.ts's cron). Its `commit` is the reported deploy's |
| 84 | * commit, or the UTC day it was taken for (`YYYY-MM-DD`). |
| 85 | */ |
| 86 | |
| 87 | /** A kept screenshot of an external production older than this is taken again. */ |
| 88 | export const EXTERNAL_MAX_AGE_MS = 24 * 60 * 60 * 1000; |
| 89 | /** External productions the daily cron takes at most, the stalest first; the rest wait a day. */ |
| 90 | export const EXTERNAL_PER_RUN = 40; |
| 91 | |
| 92 | /** An external production: the address to visit, keyed by its host. */ |
| 93 | export type ExternalShot = ShotRequest & { url: string }; |
| 94 | |
| 95 | /** Where an external production's screenshot is kept. */ |
| 96 | export function externalShotKey(host: string): string { |
| 97 | return `external/${host}.jpg`; |
| 98 | } |
| 99 | |
| 100 | /** Where the last attempt at an external production's screenshot is noted. */ |
| 101 | export function externalAttemptKey(host: string): string { |
| 102 | return `attempts/external/${host}`; |
| 103 | } |
| 104 | |
| 105 | /** The UTC day of `now`, as an external screenshot's `commit` when no deploy names one. */ |
| 106 | export function dayOf(now: number): string { |
| 107 | return new Date(now).toISOString().slice(0, 10); |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Hosts g1t serves itself and never visits as someone's production: its |
| 112 | * own services (any name under g1t.sh but g1t.sh itself), the usercontent |
| 113 | * origin, and g1t.page, whose apps are taken per deploy instead. |
| 114 | */ |
| 115 | const OWN_DOMAINS = ["g1t.sh", "g1tusercontent.com", DEPLOYMENTS_DOMAIN]; |
| 116 | /** g1t's own site is a production like any other. */ |
| 117 | const OWN_ALLOWED = new Set(["g1t.sh", "www.g1t.sh"]); |
| 118 | /** Names that only resolve on a private network, or never on the internet. */ |
| 119 | const PRIVATE_SUFFIXES = [".localhost", ".local", ".internal", ".intranet", ".lan", ".home", ".corp", ".test", ".invalid", ".example", ".onion", ".arpa"]; |
| 120 | |
| 121 | /** Whether a dotted-quad address is not a public one; null when the host is not one. */ |
| 122 | function privateIPv4(host: string): boolean | null { |
| 123 | const parts = host.split("."); |
| 124 | if (parts.length !== 4 || !parts.every((part) => /^\d{1,3}$/.test(part))) return null; |
| 125 | if (parts.some((part) => Number(part) > 255)) return true; |
| 126 | const [a, b] = parts.map(Number) as [number, number, number, number]; |
| 127 | return ( |
| 128 | a === 0 || |
| 129 | a === 10 || |
| 130 | a === 127 || |
| 131 | (a === 100 && b >= 64 && b <= 127) || |
| 132 | (a === 169 && b === 254) || |
| 133 | (a === 172 && b >= 16 && b <= 31) || |
| 134 | (a === 192 && b === 168) || |
| 135 | (a === 192 && b === 0) || |
| 136 | (a === 198 && (b === 18 || b === 19)) || |
| 137 | a >= 224 |
| 138 | ); |
| 139 | } |
| 140 | |
| 141 | /** |
| 142 | * Whether a host is a public name or address on the internet: no |
| 143 | * loopback, private, link-local, shared or reserved address, no name that |
| 144 | * only means something on a private network, and none of g1t's own |
| 145 | * services but g1t.sh. A name is not resolved here: Browser Rendering's |
| 146 | * browser runs on the public internet and cannot reach a private network |
| 147 | * whatever a name resolves to. |
| 148 | */ |
| 149 | export function isPublicHost(input: string): boolean { |
| 150 | const host = input.toLowerCase().replace(/\.$/, ""); |
| 151 | if (!host || host.length > 253) return false; |
| 152 | // An IPv6 literal: none is let through. A site is visited by its name. |
| 153 | if (host.includes(":") || host.startsWith("[")) return false; |
| 154 | const v4 = privateIPv4(host); |
| 155 | if (v4 !== null) return !v4; |
| 156 | // An integer, octal or hexadecimal form of an address. |
| 157 | if (/^(0x[0-9a-f]+|\d+)(\.(0x[0-9a-f]+|\d+))*$/.test(host)) return false; |
| 158 | if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/.test(host)) return false; |
| 159 | if (host === "localhost" || PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false; |
| 160 | if (OWN_ALLOWED.has(host)) return true; |
| 161 | return !OWN_DOMAINS.some((own) => host === own || host.endsWith(`.${own}`)); |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * The request, if it names a public http(s) address on its usual port and |
| 166 | * either a commit or a UTC day. The address keeps its path and query, |
| 167 | * without its fragment; one with a user or password is refused. |
| 168 | */ |
| 169 | export function parseExternalShot(input: unknown): ExternalShot | null { |
| 170 | if (!input || typeof input !== "object") return null; |
| 171 | const { url, commit, since } = input as Record<string, unknown>; |
| 172 | if (typeof url !== "string" || typeof commit !== "string" || url.length > 2048) return null; |
| 173 | let parsed: URL; |
| 174 | try { |
| 175 | parsed = new URL(url); |
| 176 | } catch { |
| 177 | return null; |
| 178 | } |
| 179 | if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; |
| 180 | if (parsed.username || parsed.password || parsed.port) return null; |
| 181 | if (!isPublicHost(parsed.hostname)) return null; |
| 182 | const version = commit.toLowerCase(); |
| 183 | const day = /^\d{4}-\d{2}-\d{2}$/.test(version) && Number.isFinite(Date.parse(version)); |
| 184 | if (!/^[0-9a-f]{7,64}$/.test(version) && !day) return null; |
| 185 | parsed.hash = ""; |
| 186 | const shot: ExternalShot = { host: parsed.hostname.toLowerCase().replace(/\.$/, ""), url: parsed.toString(), commit: version }; |
| 187 | if (typeof since === "string" && Number.isFinite(Date.parse(since))) shot.since = since; |
| 188 | return shot; |
| 189 | } |
| 190 | |
| 191 | /** |
| 192 | * Whether a kept external screenshot still stands for the address: taken |
| 193 | * within `EXTERNAL_MAX_AGE_MS`, and after `since` (the reported deploy). |
| 194 | */ |
| 195 | export function isFresh(kept: { capturedAt: string } | null | undefined, now: number, since?: string): boolean { |
| 196 | if (!kept) return false; |
| 197 | const at = Date.parse(kept.capturedAt); |
| 198 | if (!Number.isFinite(at) || now - at >= EXTERNAL_MAX_AGE_MS) return false; |
| 199 | return !since || !Number.isFinite(Date.parse(since)) || at >= Date.parse(since); |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * The external productions the daily cron takes: those whose screenshot |
| 204 | * is missing or not fresh, the stalest first, at most `limit`. |
| 205 | */ |
| 206 | export function dueForRefresh<T extends { capturedAt: string | null }>(targets: readonly T[], now: number, limit = EXTERNAL_PER_RUN): T[] { |
| 207 | const age = (target: T) => (target.capturedAt ? Date.parse(target.capturedAt) || 0 : 0); |
| 208 | return targets |
| 209 | .filter((target) => !isFresh(target.capturedAt ? { capturedAt: target.capturedAt } : null, now)) |
| 210 | .sort((a, b) => age(a) - age(b)) |
| 211 | .slice(0, limit); |
| 212 | } |