Skip to content
92 linesCodeBlameRaw
1/**
2 * A project's uploaded production image: shown on its overview where a
3 * screenshot of production would be, when there is none or when it is
4 * preferred. Kept in the avatars namespace under `production/<sha256>`,
5 * with the type read from its bytes, and served from the usercontent
6 * origin at `/production-images/<sha256>` (apps/web/workers/usercontent.ts),
7 * never from the site itself. Types only, so it is tested apart from the
8 * service.
9 */
10
11import type { ProductionImage } from "@g1t/contracts";
12
13/** As `MAX_PRODUCTION_IMAGE_BYTES` and `PRODUCTION_IMAGE_TYPES` in the contracts: kept here so tests run without them. */
14export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024;
15const PRODUCTION_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
16
17/** The SHA-256 of the bytes, in hex: the image's name. */
18export async function sha256(bytes: Uint8Array): Promise<string> {
19 const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
20 return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
21}
22
23/** The UTC day, as the screenshot of a production deployed elsewhere is versioned when no deploy names a commit. */
24export function today(now = Date.now()): string {
25 return new Date(now).toISOString().slice(0, 10);
26}
27
28/** Where an image is kept in the avatars namespace. */
29export function productionImageKey(file: string): string {
30 return `production/${file}`;
31}
32
33/** The type an image's first bytes show it to be, if it is one a production image may be. */
34export function sniffProductionImage(bytes: Uint8Array): string | null {
35 const starts = (...prefix: number[]) => prefix.every((byte, i) => bytes[i] === byte);
36 const ascii = (at: number, text: string) => [...text].every((char, i) => bytes[at + i] === char.charCodeAt(0));
37 let type: string | null = null;
38 if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) type = "image/png";
39 else if (starts(0xff, 0xd8, 0xff)) type = "image/jpeg";
40 else if (bytes.length >= 12 && ascii(0, "RIFF") && ascii(8, "WEBP")) type = "image/webp";
41 return type && PRODUCTION_IMAGE_TYPES.includes(type) ? type : null;
42}
43
44/** Base64 to bytes; null when it is not base64. */
45export function fromBase64(data: unknown): Uint8Array | null {
46 if (typeof data !== "string" || !data) return null;
47 try {
48 return Uint8Array.from(atob(data), (char) => char.charCodeAt(0));
49 } catch {
50 return null;
51 }
52}
53
54export type CheckedImage = { ok: true; bytes: Uint8Array; contentType: string } | { ok: false; message: string };
55
56/** An uploaded image, checked by its bytes: a PNG, JPEG or WebP of at most 5 MB. */
57export function checkProductionImage(data: unknown): CheckedImage {
58 const bytes = fromBase64(data);
59 if (!bytes || bytes.length === 0) return { ok: false, message: "Choose a PNG, JPEG or WebP image." };
60 if (bytes.length > MAX_PRODUCTION_IMAGE_BYTES) {
61 return { ok: false, message: `Use an image of at most ${MAX_PRODUCTION_IMAGE_BYTES / 1024 / 1024} MB.` };
62 }
63 const contentType = sniffProductionImage(bytes);
64 if (!contentType) return { ok: false, message: "Use a PNG, JPEG or WebP image." };
65 return { ok: true, bytes, contentType };
66}
67
68/** The image's stored form: its hash and type, or null for none. */
69export function productionImageOf(row: {
70 production_image?: string | null;
71 production_image_type?: string | null;
72 production_image_preferred?: number | null;
73}): ProductionImage | null {
74 if (!row.production_image) return null;
75 return {
76 file: row.production_image,
77 contentType: row.production_image_type ?? "image/png",
78 preferred: !!row.production_image_preferred,
79 };
80}
81
82/**
83 * Whether the project's production should be visited for a screenshot: it
84 * runs elsewhere, at an address, and no uploaded image is shown instead.
85 */
86export function wantsScreenshot(project: {
87 runs: string | null;
88 productionUrl: string | null;
89 productionImage: { preferred: boolean } | null;
90}): boolean {
91 return project.runs === "elsewhere" && !!project.productionUrl && !project.productionImage?.preferred;
92}