| 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 | |
| 11 | import { sniffImage, toBase64 } from "./avatar-upload.ts"; |
| 12 | |
| 13 | /** As `MAX_PRODUCTION_IMAGE_BYTES` in the contracts: kept here so tests run without them. */ |
| 14 | export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024; |
| 15 | |
| 16 | /** What the file picker offers: PNG, JPEG and WebP. */ |
| 17 | export const PRODUCTION_IMAGE_ACCEPT = "image/png,image/jpeg,image/webp"; |
| 18 | |
| 19 | const TYPES = new Set(PRODUCTION_IMAGE_ACCEPT.split(",")); |
| 20 | |
| 21 | /** Where an uploaded production image is served. */ |
| 22 | export function productionImageUrl(file: string, usercontent = ""): string { |
| 23 | return `${usercontent}/production-images/${file}`; |
| 24 | } |
| 25 | |
| 26 | export type ProductionImageUpload = { image: string } | { error: string }; |
| 27 | |
| 28 | /** Reads the image in `field` of a submitted form, as base64 for the projects service. */ |
| 29 | export 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. */ |
| 40 | export 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. */ |
| 43 | export 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`. */ |
| 52 | export 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 | */ |
| 62 | export 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. */ |
| 84 | export 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 | */ |
| 93 | export 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. */ |
| 100 | export type CardSource = { src: string; kind: "screenshot" | "upload" }; |