| 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 | |
| 11 | import 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. */ |
| 14 | export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024; |
| 15 | const PRODUCTION_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"]; |
| 16 | |
| 17 | /** The SHA-256 of the bytes, in hex: the image's name. */ |
| 18 | export 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. */ |
| 24 | export 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. */ |
| 29 | export 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. */ |
| 34 | export 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. */ |
| 45 | export 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 | |
| 54 | export 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. */ |
| 57 | export 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. */ |
| 69 | export 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 | */ |
| 86 | export 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 | } |