Skip to content
100 linesCodeBlameRaw
1/**
2 * The image on a project's production card: a screenshot of production
3 * (of its app on g1t.page, or of its address when it is deployed
4 * elsewhere), else an image someone uploaded, else a placeholder. An
5 * uploaded image is served from the usercontent origin at
6 * `/production-images/<sha256>`; the projects service checks it again by
7 * its bytes and is the one that decides. No Workers imports, so it is
8 * tested under Node.
9 */
10
11import { sniffImage, toBase64 } from "./avatar-upload.ts";
12
13/** As `MAX_PRODUCTION_IMAGE_BYTES` in the contracts: kept here so tests run without them. */
14export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024;
15
16/** What the file picker offers: PNG, JPEG and WebP. */
17export const PRODUCTION_IMAGE_ACCEPT = "image/png,image/jpeg,image/webp";
18
19const TYPES = new Set(PRODUCTION_IMAGE_ACCEPT.split(","));
20
21/** Where an uploaded production image is served. */
22export function productionImageUrl(file: string, usercontent = ""): string {
23 return `${usercontent}/production-images/${file}`;
24}
25
26export type ProductionImageUpload = { image: string } | { error: string };
27
28/** Reads the image in `field` of a submitted form, as base64 for the projects service. */
29export async function readProductionImageUpload(form: FormData, field = "image"): Promise<ProductionImageUpload> {
30 const file = form.get(field);
31 if (!file || typeof file === "string" || file.size === 0) return { error: "Choose an image to upload." };
32 if (file.size > MAX_PRODUCTION_IMAGE_BYTES) return { error: "Use an image of at most 5 MB." };
33 const bytes = new Uint8Array(await file.arrayBuffer());
34 const type = sniffImage(bytes);
35 if (!type || !TYPES.has(type)) return { error: "Use a PNG, JPEG or WebP image." };
36 return { image: toBase64(bytes) };
37}
38
39/** A production deployment reported from outside g1t.page, as the overview shows it. */
40export type ReportedProduction = { url: string | null; sha: string; created_at: string };
41
42/** A production deployed elsewhere, to take a screenshot of: its address and what it shows. */
43export type ExternalTarget = {
44 url: string;
45 /** The reported deploy's commit, or the UTC day when none is reported. */
46 commit: string;
47 /** When the reported deploy was made; a screenshot from before it is taken again. */
48 since: string | null;
49};
50
51/** The UTC day of `now`. */
52export function utcDay(now: number): string {
53 return new Date(now).toISOString().slice(0, 10);
54}
55
56/**
57 * The production deployed elsewhere whose screenshot the card shows: at
58 * the address its reported production deployment gives, else the
59 * project's own. Null when it is not deployed elsewhere, has no address,
60 * or shows its uploaded image instead.
61 */
62export function externalTarget(
63 project: {
64 kind: string;
65 runs: string | null;
66 productionUrl: string | null;
67 productionImage: { preferred: boolean } | null;
68 },
69 reported: ReportedProduction | null,
70 now: number,
71): ExternalTarget | null {
72 if (project.productionImage?.preferred) return null;
73 // An app that has not said where it runs, but reports production from elsewhere, runs elsewhere.
74 const elsewhere = project.runs === "elsewhere" || (project.runs === null && project.kind === "app" && reported != null);
75 if (!elsewhere) return null;
76 const url = reported?.url ?? project.productionUrl;
77 if (!url || !/^https?:\/\//i.test(url)) return null;
78 const sha = reported?.sha?.toLowerCase() ?? "";
79 if (reported && /^[0-9a-f]{7,64}$/.test(sha)) return { url, commit: sha, since: reported.created_at };
80 return { url, commit: utcDay(now), since: null };
81}
82
83/** What names one screenshot of an external production, for its address on the site: changes with a deploy or a day. */
84export function externalVersion(target: ExternalTarget): string {
85 return `${target.commit}-${(target.since && Date.parse(target.since)) || 0}`;
86}
87
88/**
89 * The images the card tries, in order, falling to the next when one does
90 * not load: the uploaded image alone when it is preferred; else the
91 * screenshot, then the uploaded image. Empty: the placeholder.
92 */
93export function cardSources(screenshot: string | null, upload: { url: string; preferred: boolean } | null): CardSource[] {
94 const uploaded: CardSource[] = upload ? [{ src: upload.url, kind: "upload" }] : [];
95 if (upload?.preferred) return uploaded;
96 return [...(screenshot ? [{ src: screenshot, kind: "screenshot" as const }] : []), ...uploaded];
97}
98
99/** One image the card may show, and what it is. */
100export type CardSource = { src: string; kind: "screenshot" | "upload" };