Skip to content
212 linesCodeBlameRaw
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
9export const DEPLOYMENTS_DOMAIN = "g1t.page";
10/** The page is drawn at this size; the overview shows it scaled down. */
11export const VIEWPORT = { width: 1280, height: 800 };
12/** How long a page has to settle before it is taken anyway. */
13export const SETTLE_MS = 12_000;
14/** A screenshot that could not be taken is tried again after this long. */
15export 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 */
23export 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. */
26export const KEEP_DAYS = 30;
27
28/** Hostnames on g1t.page that are not apps. */
29const RESERVED = new Set(["domains", "www"]);
30
31/** The request, if it names an app on g1t.page and a commit. */
32export 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. */
48export function shotKey(host: string): string {
49 return `production/${host}.jpg`;
50}
51
52/** Where the last attempt at an app's screenshot is noted. */
53export 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. */
58export 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. */
65export 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`. */
70export 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. */
88export 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. */
90export const EXTERNAL_PER_RUN = 40;
91
92/** An external production: the address to visit, keyed by its host. */
93export type ExternalShot = ShotRequest & { url: string };
94
95/** Where an external production's screenshot is kept. */
96export 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. */
101export 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. */
106export 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 */
115const OWN_DOMAINS = ["g1t.sh", "g1tusercontent.com", DEPLOYMENTS_DOMAIN];
116/** g1t's own site is a production like any other. */
117const OWN_ALLOWED = new Set(["g1t.sh", "www.g1t.sh"]);
118/** Names that only resolve on a private network, or never on the internet. */
119const 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. */
122function 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 */
149export 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 */
169export 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 */
195export 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 */
206export 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}