Merge branch 'worktree-agent-a5b0d9e2733e9e382'
30 files+1444−1550/30 viewed
| 181 | 181 | seconds for the page to settle, then takes it as it is. | |
| 182 | 182 | - The screenshot is of the app's `g1t.page` address. A custom domain | |
| 183 | 183 | serves the same app, so it looks the same. | |
| 184 | − | - Until it is ready, or if it could not be taken, the overview shows a | |
| 185 | − | plain frame with the address. A failed screenshot is tried again when | |
| 186 | − | the overview is next opened, at most every five minutes. | |
| 187 | − | - Only people who can read the project's repository see it, like the | |
| 188 | − | rest of its deployments. | |
| 184 | + | - Until it is ready, or if it could not be taken, the overview shows the | |
| 185 | + | project's uploaded image, if it has one, or else a plain frame with the | |
| 186 | + | address. A failed screenshot is tried again when the overview is next | |
| 187 | + | opened, at most every five minutes. | |
| 188 | + | - Only people with a role on the project's repository see it, like the | |
| 189 | + | rest of its deployments; anyone else who can see the project sees its | |
| 190 | + | uploaded image or the frame. | |
| 191 | + | - With **Use the uploaded image instead of a screenshot** on, the | |
| 192 | + | uploaded image shows instead. | |
| 189 | 193 | - Screenshots are not charged. | |
| 190 | 194 | ||
| 195 | + | An app deployed elsewhere gets a screenshot of its production address | |
| 196 | + | too, and any project can show an uploaded image instead. See | |
| 197 | + | [production's picture](/guides/projects/#productions-picture). | |
| 198 | + | ||
| 191 | 199 | The [checklist on the overview](/guides/projects/#get-to-production) also | |
| 192 | 200 | tracks the first production deploy, a custom domain and a first preview. | |
| 193 | 201 |
| 54 | 54 | | Part | What it shows | | |
| 55 | 55 | | --- | --- | | |
| 56 | 56 | | **What it is** | A badge, such as **App, deployed elsewhere** or **Library**, beside its homepage and docs. People who can change its settings open the badge to change [what it is](#what-a-project-is) in one click. | | |
| 57 | − | | **Production** | For an app [deployed on g1t](/guides/deployments/): a screenshot of the live site, which opens it; its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 57 | + | | **Production** | [Production's picture](#productions-picture), which opens the live site, beside the rest. For an app [deployed on g1t](/guides/deployments/): its address, the commit it runs and when it went up; **Visit** and **Redeploy**. For an app deployed elsewhere: production's address and **Visit**, with its latest reported production deployment or the default branch's latest commit. For an app nobody has placed yet: one question, where it runs. | | |
| 58 | 58 | | **Packages** | For a library or a tool, in place of Production: the packages its repository publishes, each with its latest version and how to install it, or how to publish a first one. | | |
| 59 | 59 | | **Documentation**, **Homepage** | For docs, where they are read; for anything else, its homepage. | | |
| 60 | 60 | | **Get to production**, **Ship a release** or **Get started** | The [checklist](#the-checklist) for what the project is, until every step is done or you dismiss it. | | |
| ⋯ | |||
| 65 | 65 | | **About** | Its description, its [links](#links), what it is, and its latest release (its newest tag). People who can change its settings edit the description and links from here. | | |
| 66 | 66 | | **Health**, **Clone** | How often checks pass (on pull requests, or the project's workflow runs when it ships without them), recent builds and open issues by age; the clone address. | | |
| 67 | 67 | ||
| 68 | + | ### Production's picture | |
| 69 | + | ||
| 70 | + | The production card always has a picture of the app, wherever it runs. | |
| 71 | + | It is the first of these there is: | |
| 72 | + | ||
| 73 | + | 1. **A screenshot of production.** For an app deployed on g1t, g1t takes | |
| 74 | + | one each time production goes live (see | |
| 75 | + | [the production screenshot](/guides/deployments/#the-production-screenshot)); | |
| 76 | + | people with a role on the repository see it, like the rest of its | |
| 77 | + | deployments. For an app deployed elsewhere, g1t takes one of | |
| 78 | + | production's address: the address its latest production deployment | |
| 79 | + | reported to g1t gives, or else the one set for it under **What it is**. | |
| 80 | + | Anyone who can see the project sees it. | |
| 81 | + | 2. **An image someone uploaded**, under **Settings** → **General** → | |
| 82 | + | **Production image**. | |
| 83 | + | 3. **A plain frame** with production's address. People who can change | |
| 84 | + | the project's settings see **Add an image of your app** on it, which | |
| 85 | + | opens **Production image**. | |
| 86 | + | ||
| 87 | + | A screenshot of production deployed elsewhere is taken: | |
| 88 | + | ||
| 89 | + | - when its address is set or changed; | |
| 90 | + | - when a production deployment of it is reported to g1t and succeeds, | |
| 91 | + | through the [API](/guides/deployments/) or by a workflow job with an | |
| 92 | + | `environment:`; | |
| 93 | + | - otherwise about once a day, when the one kept is a day old. Opening the | |
| 94 | + | overview after that also takes a new one, in the background, while the | |
| 95 | + | old one shows. | |
| 96 | + | ||
| 97 | + | g1t waits up to 12 seconds for the page to settle, as a 1280 by 800 | |
| 98 | + | browser window sees it, then takes it as it is. Only public `http` and | |
| 99 | + | `https` addresses on their usual ports are visited: an address on a | |
| 100 | + | private network, a loopback or link-local address, or one of g1t's own | |
| 101 | + | service addresses (other than `g1t.sh` itself) gets the uploaded image | |
| 102 | + | or the frame instead. Screenshots are not charged. | |
| 103 | + | ||
| 104 | + | #### Uploading an image | |
| 105 | + | ||
| 106 | + | **Production image** takes a PNG, JPEG or WebP of at most 5 MB; wide | |
| 107 | + | images fit best, such as 1280 by 800. It is checked by its contents, not | |
| 108 | + | its name, and served from g1t's user-content address | |
| 109 | + | (`g1tusercontent.com` on g1t.sh), never from the site itself. Its address | |
| 110 | + | is made from its contents, so the card shows a new image as soon as it | |
| 111 | + | is uploaded. Anyone with an image's address can open it, even for a | |
| 112 | + | private project, so upload only what you would show. | |
| 113 | + | ||
| 114 | + | - The uploaded image shows whenever there is no screenshot: before the | |
| 115 | + | first one, when one cannot be taken, or for an app with no production | |
| 116 | + | address. | |
| 117 | + | - **Use the uploaded image instead of a screenshot** shows it in place of | |
| 118 | + | any screenshot. While it is on, g1t takes no screenshots of production | |
| 119 | + | deployed elsewhere. | |
| 120 | + | - **Remove** takes it away; the card goes back to the screenshot or the | |
| 121 | + | frame. | |
| 122 | + | ||
| 123 | + | Anyone who can change the project's settings (the **Maintain** role on | |
| 124 | + | its repository and up, as for production's address) can upload, replace | |
| 125 | + | or remove it. | |
| 126 | + | ||
| 68 | 127 | ### The checklist | |
| 69 | 128 | ||
| 70 | 129 | People with a role on its repository see a card that counts what the | |
| ⋯ | |||
| 216 | 275 | ||
| 217 | 276 | | Tab | What it holds | | |
| 218 | 277 | | --- | --- | | |
| 219 | − | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), and its [links](#links). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 278 | + | | **General** | The project's name and description, its source, its **root directory**, [what it is](#what-a-project-is), its [links](#links), and its [production image](#uploading-an-image). A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 220 | 279 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 221 | 280 | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 222 | 281 | | **Agents** | How g1t's agents pick up work here, and what they read first. | | |
| 1 | + | import { ImageUp, LoaderCircle, Trash2 } from "lucide-react"; | |
| 2 | + | import { useRef } from "react"; | |
| 3 | + | import { useFetcher } from "react-router"; | |
| 4 | + | ||
| 5 | + | import type { ProductionImage } from "@g1t/contracts"; | |
| 6 | + | ||
| 7 | + | import { PRODUCTION_IMAGE_ACCEPT } from "../lib/production-image"; | |
| 8 | + | import { ErrorText, SubmitButton, usePending } from "./ui"; | |
| 9 | + | import { Button } from "./ui/button"; | |
| 10 | + | import { Card } from "./ui/card"; | |
| 11 | + | import { SwitchCard } from "./ui/switch"; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * A project's production image, in its settings: the one shown now, a | |
| 15 | + | * button to pick a new one (sent as soon as it is picked), a way to remove | |
| 16 | + | * it, and whether it is shown instead of a screenshot. Posts | |
| 17 | + | * `intent=production-image` with the file in `image`, | |
| 18 | + | * `intent=remove-production-image`, or `intent=prefer-production-image` | |
| 19 | + | * with `preferred`. | |
| 20 | + | */ | |
| 21 | + | export function ProductionImageField({ | |
| 22 | + | image, | |
| 23 | + | src, | |
| 24 | + | screenshots, | |
| 25 | + | }: { | |
| 26 | + | image: ProductionImage | null; | |
| 27 | + | /** Where the image is served, when there is one. */ | |
| 28 | + | src: string | null; | |
| 29 | + | /** Whether production gets a screenshot by itself: on g1t.page, or at an address elsewhere. */ | |
| 30 | + | screenshots: boolean; | |
| 31 | + | }) { | |
| 32 | + | const upload = useFetcher<{ error?: string }>(); | |
| 33 | + | const prefer = useFetcher<{ error?: string }>(); | |
| 34 | + | const form = useRef<HTMLFormElement>(null); | |
| 35 | + | const uploading = usePending({ intent: "production-image" }, upload); | |
| 36 | + | const removing = usePending({ intent: "remove-production-image" }, upload); | |
| 37 | + | const busy = uploading || removing; | |
| 38 | + | // Shown as asked while it is being saved. | |
| 39 | + | const preferred = prefer.formData ? prefer.formData.get("preferred") === "on" : (image?.preferred ?? false); | |
| 40 | + | return ( | |
| 41 | + | <Card className="p-4"> | |
| 42 | + | <div className="flex flex-col gap-4 sm:flex-row sm:items-start"> | |
| 43 | + | <div className="aspect-16/10 w-full shrink-0 overflow-hidden rounded-lg border border-line bg-bg sm:w-48"> | |
| 44 | + | {src ? ( | |
| 45 | + | <img src={src} alt="The uploaded image of production" width={1280} height={800} className="size-full object-cover object-top" /> | |
| 46 | + | ) : ( | |
| 47 | + | <div className="flex size-full items-center justify-center text-faint"> | |
| 48 | + | <ImageUp size={20} aria-hidden="true" /> | |
| 49 | + | </div> | |
| 50 | + | )} | |
| 51 | + | </div> | |
| 52 | + | <div className="min-w-0 grow space-y-2"> | |
| 53 | + | <p className="text-sm text-muted"> | |
| 54 | + | {screenshots | |
| 55 | + | ? "Shown on the overview while there is no screenshot of production, or instead of one." | |
| 56 | + | : "Shown on the overview's production card. Give production's address, and a screenshot of it is taken by itself."} | |
| 57 | + | </p> | |
| 58 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 59 | + | <upload.Form method="post" encType="multipart/form-data" ref={form}> | |
| 60 | + | <input type="hidden" name="intent" value="production-image" /> | |
| 61 | + | <Button asChild variant="outline" className={busy ? "pointer-events-none opacity-50" : "cursor-pointer"}> | |
| 62 | + | <label> | |
| 63 | + | {uploading ? <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> : <ImageUp size={15} />} | |
| 64 | + | {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"} | |
| 65 | + | <input | |
| 66 | + | type="file" | |
| 67 | + | name="image" | |
| 68 | + | accept={PRODUCTION_IMAGE_ACCEPT} | |
| 69 | + | className="sr-only" | |
| 70 | + | onChange={(event) => { | |
| 71 | + | if (event.currentTarget.files?.length) form.current?.requestSubmit(); | |
| 72 | + | }} | |
| 73 | + | /> | |
| 74 | + | </label> | |
| 75 | + | </Button> | |
| 76 | + | </upload.Form> | |
| 77 | + | {image && ( | |
| 78 | + | <upload.Form method="post"> | |
| 79 | + | <input type="hidden" name="intent" value="remove-production-image" /> | |
| 80 | + | <SubmitButton fetcher={upload} variant="outline" pending="Removing…" match={{ intent: "remove-production-image" }} disabled={uploading}> | |
| 81 | + | <Trash2 size={15} /> | |
| 82 | + | Remove | |
| 83 | + | </SubmitButton> | |
| 84 | + | </upload.Form> | |
| 85 | + | )} | |
| 86 | + | </div> | |
| 87 | + | <p className="text-xs text-faint">PNG, JPEG or WebP, at most 5 MB. Wide images fit best, such as 1280 × 800.</p> | |
| 88 | + | <ErrorText>{upload.data?.error ?? prefer.data?.error}</ErrorText> | |
| 89 | + | </div> | |
| 90 | + | </div> | |
| 91 | + | {image && screenshots && ( | |
| 92 | + | <SwitchCard | |
| 93 | + | className="mt-4" | |
| 94 | + | title="Use the uploaded image instead of a screenshot" | |
| 95 | + | checked={preferred} | |
| 96 | + | onCheckedChange={(on) => prefer.submit({ intent: "prefer-production-image", preferred: on ? "on" : "" }, { method: "post" })} | |
| 97 | + | > | |
| 98 | + | No screenshot of production is taken while it is on. | |
| 99 | + | </SwitchCard> | |
| 100 | + | )} | |
| 101 | + | </Card> | |
| 102 | + | ); | |
| 103 | + | } |
| 1 | − | import { Globe } from "lucide-react"; | |
| 1 | + | import { Globe, ImagePlus } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; | |
| 3 | 3 | ||
| 4 | 4 | import { cn } from "../lib/cn"; | |
| 5 | + | import type { CardSource } from "../lib/production-image"; | |
| 5 | 6 | import { DeployLink } from "./deploy"; | |
| 7 | + | import { ButtonLink } from "./ui"; | |
| 6 | 8 | ||
| 7 | 9 | /** | |
| 8 | − | * A thumbnail of production as it is now, linking to it. The screenshot is | |
| 9 | − | * taken once per deploy; until it has loaded, or when there is none yet, a | |
| 10 | − | * quiet frame with the address stands in. | |
| 10 | + | * A thumbnail of production, linking to it: a screenshot (taken once per | |
| 11 | + | * deploy on g1t.page, or of the address of production deployed | |
| 12 | + | * elsewhere), else an image someone uploaded (lib/production-image.ts | |
| 13 | + | * `cardSources`). Each source is tried in turn, the next when one does not | |
| 14 | + | * load. Until one has loaded, or when there is none, a quiet frame with | |
| 15 | + | * the address stands in; to someone who may change the project's | |
| 16 | + | * settings it offers to add an image. | |
| 11 | 17 | */ | |
| 12 | 18 | export function ProductionShot({ | |
| 13 | − | src, | |
| 19 | + | sources, | |
| 14 | 20 | href, | |
| 15 | 21 | label, | |
| 22 | + | note, | |
| 23 | + | addImage, | |
| 16 | 24 | className, | |
| 17 | 25 | }: { | |
| 18 | − | /** Null when nothing is deployed yet. */ | |
| 19 | − | src: string | null; | |
| 26 | + | /** The images to try, in order; empty for none. */ | |
| 27 | + | sources: CardSource[]; | |
| 20 | 28 | href: string | null; | |
| 21 | 29 | label: string; | |
| 30 | + | /** What the empty frame says, such as when a screenshot comes; none for a quiet frame. */ | |
| 31 | + | note?: string | null; | |
| 32 | + | /** Where an image is added, for someone who may; null for anyone else. */ | |
| 33 | + | addImage?: string | null; | |
| 22 | 34 | className?: string; | |
| 23 | 35 | }) { | |
| 24 | 36 | const image = useRef<HTMLImageElement>(null); | |
| 25 | − | const [state, setState] = useState<"loading" | "loaded" | "failed">("loading"); | |
| 37 | + | const [at, setAt] = useState(0); | |
| 38 | + | const [loaded, setLoaded] = useState(false); | |
| 39 | + | const key = sources.map((source) => source.src).join("\n"); | |
| 40 | + | const source = sources[at] ?? null; | |
| 41 | + | ||
| 42 | + | // Other sources (another deploy, a new upload): from the first again. | |
| 43 | + | useEffect(() => { | |
| 44 | + | setAt(0); | |
| 45 | + | setLoaded(false); | |
| 46 | + | }, [key]); | |
| 26 | 47 | ||
| 27 | 48 | // An image that settled before hydration fired its events unheard. | |
| 28 | 49 | useEffect(() => { | |
| 29 | 50 | const img = image.current; | |
| 30 | 51 | if (!img?.complete) return; | |
| 31 | − | setState(img.naturalWidth > 0 ? "loaded" : "failed"); | |
| 32 | − | }, [src]); | |
| 52 | + | if (img.naturalWidth > 0) setLoaded(true); | |
| 53 | + | else setAt((index) => index + 1); | |
| 54 | + | }, [source?.src]); | |
| 55 | + | ||
| 56 | + | const empty = source == null; | |
| 57 | + | const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null; | |
| 33 | 58 | ||
| 34 | 59 | const frame = ( | |
| 35 | 60 | <span | |
| 36 | 61 | className={cn( | |
| 37 | − | "relative block aspect-[16/10] w-full overflow-hidden rounded-lg border border-line bg-bg", | |
| 62 | + | "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg", | |
| 38 | 63 | href && "transition-colors group-hover:border-line-strong", | |
| 39 | 64 | )} | |
| 40 | 65 | > | |
| 41 | − | {state !== "loaded" && <Placeholder label={label} waiting={src != null && state === "loading"} />} | |
| 42 | − | {src && state !== "failed" && ( | |
| 66 | + | {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />} | |
| 67 | + | {source && ( | |
| 43 | 68 | <img | |
| 69 | + | key={source.src} | |
| 44 | 70 | ref={image} | |
| 45 | − | src={src} | |
| 46 | − | alt={`Screenshot of ${label}`} | |
| 71 | + | src={source.src} | |
| 72 | + | alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`} | |
| 47 | 73 | width={1280} | |
| 48 | 74 | height={800} | |
| 49 | 75 | decoding="async" | |
| 50 | − | onLoad={() => setState("loaded")} | |
| 51 | − | onError={() => setState("failed")} | |
| 76 | + | onLoad={() => setLoaded(true)} | |
| 77 | + | onError={() => { | |
| 78 | + | setLoaded(false); | |
| 79 | + | setAt((index) => index + 1); | |
| 80 | + | }} | |
| 52 | 81 | className={cn( | |
| 53 | 82 | "absolute inset-0 size-full object-cover object-top transition-opacity duration-300", | |
| 54 | − | state === "loaded" ? "opacity-100" : "opacity-0", | |
| 83 | + | loaded ? "opacity-100" : "opacity-0", | |
| 55 | 84 | )} | |
| 56 | 85 | /> | |
| 57 | 86 | )} | |
| 58 | 87 | </span> | |
| 59 | 88 | ); | |
| 60 | 89 | ||
| 61 | − | return href ? ( | |
| 62 | − | <DeployLink href={href} className={cn("group block", className)} aria-label={`Visit ${label}`}> | |
| 63 | − | {frame} | |
| 64 | − | </DeployLink> | |
| 65 | − | ) : ( | |
| 66 | − | <div className={className}>{frame}</div> | |
| 90 | + | return ( | |
| 91 | + | <div className={cn("relative", className)}> | |
| 92 | + | {href ? ( | |
| 93 | + | <DeployLink href={href} className="group block" aria-label={`Visit ${label}`}> | |
| 94 | + | {frame} | |
| 95 | + | </DeployLink> | |
| 96 | + | ) : ( | |
| 97 | + | frame | |
| 98 | + | )} | |
| 99 | + | {empty && addImage && ( | |
| 100 | + | // Beside the link, not in it: a link holds no other link. | |
| 101 | + | <span className="pointer-events-none absolute inset-x-0 top-7 bottom-0 flex flex-col items-center justify-center gap-2 px-3 text-center"> | |
| 102 | + | <ButtonLink to={addImage} variant="outline" size="xs" className="pointer-events-auto bg-surface"> | |
| 103 | + | <ImagePlus size={13} /> | |
| 104 | + | Add an image of your app | |
| 105 | + | </ButtonLink> | |
| 106 | + | {note && <span className="text-[0.6875rem] text-faint">{note}</span>} | |
| 107 | + | </span> | |
| 108 | + | )} | |
| 109 | + | </div> | |
| 67 | 110 | ); | |
| 68 | 111 | } | |
| 69 | 112 | ||
| 70 | − | function Placeholder({ label, waiting }: { label: string; waiting: boolean }) { | |
| 113 | + | /** The frame without a picture: a browser's bar with the address, and faint lines of a page. */ | |
| 114 | + | function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) { | |
| 115 | + | const line = waiting ?? note; | |
| 71 | 116 | return ( | |
| 72 | 117 | <span className="absolute inset-0 flex flex-col"> | |
| 73 | 118 | <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5"> | |
| 74 | 119 | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 75 | 120 | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 76 | 121 | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 77 | − | <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span> | |
| 122 | + | {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>} | |
| 78 | 123 | </span> | |
| 79 | 124 | <span className="relative flex grow flex-col gap-2 p-3.5"> | |
| 80 | 125 | <span className="h-2 w-2/5 rounded-full bg-line/80" /> | |
| 81 | 126 | <span className="h-1.5 w-4/5 rounded-full bg-line/50" /> | |
| 82 | 127 | <span className="h-1.5 w-3/5 rounded-full bg-line/50" /> | |
| 83 | − | <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint"> | |
| 84 | − | <Globe size={12} className={waiting ? "animate-pulse" : undefined} /> | |
| 85 | − | {waiting ? "Taking a screenshot…" : "A screenshot appears after the next deploy"} | |
| 86 | − | </span> | |
| 128 | + | {line && ( | |
| 129 | + | <span className="mt-auto flex items-center gap-1.5 text-[0.6875rem] text-faint"> | |
| 130 | + | <Globe size={12} className={waiting ? "animate-pulse" : undefined} /> | |
| 131 | + | {line} | |
| 132 | + | </span> | |
| 133 | + | )} | |
| 87 | 134 | </span> | |
| 88 | 135 | </span> | |
| 89 | 136 | ); |
| 132 | 132 | deployment, | |
| 133 | 133 | commit, | |
| 134 | 134 | checks, | |
| 135 | + | shot, | |
| 135 | 136 | }: Head & { | |
| 136 | 137 | deployment?: ExternalDeployment | null; | |
| 137 | 138 | commit: { hash: string; authoredAt: string } | null; | |
| 138 | 139 | /** The commit's checks, as a badge; left out until there is one to show. */ | |
| 139 | 140 | checks?: ReactNode; | |
| 141 | + | /** Production's picture, beside the rest, as an app on g1t has (components/production-shot.tsx). */ | |
| 142 | + | shot?: ReactNode; | |
| 140 | 143 | }) { | |
| 141 | 144 | const url = deployment?.url ?? project.productionUrl; | |
| 142 | 145 | return ( | |
| 143 | − | <div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:p-6"> | |
| 144 | − | <div className="min-w-0"> | |
| 145 | − | <HeadLabel icon={<Rocket />}>Production</HeadLabel> | |
| 146 | − | {url ? ( | |
| 147 | − | <> | |
| 148 | − | <BigLink url={url} /> | |
| 149 | − | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> | |
| 150 | − | {deployment ? ( | |
| 151 | − | <> | |
| 152 | − | <StatusDot status={deploymentStatus(deployment.state)} /> | |
| 153 | − | <span className="inline-flex items-center gap-1 font-mono"> | |
| 154 | − | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 155 | − | {deployment.sha.slice(0, 7)} | |
| 156 | − | </span> | |
| 157 | − | <span> | |
| 158 | − | deployed <TimeAgo at={deployment.created_at} /> | |
| 159 | − | </span> | |
| 160 | − | </> | |
| 161 | − | ) : ( | |
| 162 | − | <> | |
| 163 | − | <span>Deployed by its own pipeline</span> | |
| 164 | − | {commit && ( | |
| 165 | − | <span className="inline-flex items-center gap-1.5"> | |
| 166 | − | <span className="text-faint">·</span> | |
| 167 | − | <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg"> | |
| 168 | − | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 169 | − | {commit.hash.slice(0, 7)} | |
| 170 | − | </Link> | |
| 171 | − | {checks} | |
| 172 | − | <TimeAgo at={commit.authoredAt} /> | |
| 146 | + | <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6"> | |
| 147 | + | {shot} | |
| 148 | + | <div className="flex min-w-0 grow flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"> | |
| 149 | + | <div className="min-w-0"> | |
| 150 | + | <HeadLabel icon={<Rocket />}>Production</HeadLabel> | |
| 151 | + | {url ? ( | |
| 152 | + | <> | |
| 153 | + | <BigLink url={url} /> | |
| 154 | + | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> | |
| 155 | + | {deployment ? ( | |
| 156 | + | <> | |
| 157 | + | <StatusDot status={deploymentStatus(deployment.state)} /> | |
| 158 | + | <span className="inline-flex items-center gap-1 font-mono"> | |
| 159 | + | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 160 | + | {deployment.sha.slice(0, 7)} | |
| 161 | + | </span> | |
| 162 | + | <span> | |
| 163 | + | deployed <TimeAgo at={deployment.created_at} /> | |
| 173 | 164 | </span> | |
| 174 | − | )} | |
| 175 | − | </> | |
| 176 | − | )} | |
| 177 | − | </p> | |
| 178 | − | </> | |
| 179 | − | ) : canChange ? ( | |
| 180 | − | <> | |
| 181 | − | <p className="mt-2 text-lg font-medium">Where is production?</p> | |
| 182 | − | <p className="mt-1 max-w-lg text-sm text-muted"> | |
| 183 | − | {project.name} is deployed by its own pipeline. Give production's address, and this card links to it. | |
| 184 | − | </p> | |
| 185 | − | <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} /> | |
| 186 | − | </> | |
| 187 | − | ) : ( | |
| 188 | − | <p className="mt-2 text-sm text-muted">Deployed by its own pipeline.</p> | |
| 165 | + | </> | |
| 166 | + | ) : ( | |
| 167 | + | <> | |
| 168 | + | <span>Deployed by its own pipeline</span> | |
| 169 | + | {commit && ( | |
| 170 | + | <span className="inline-flex items-center gap-1.5"> | |
| 171 | + | <span className="text-faint">·</span> | |
| 172 | + | <Link to={`${base}/commit/${commit.hash}`} className="inline-flex items-center gap-1 font-mono hover:text-fg"> | |
| 173 | + | <GitCommitHorizontal size={13} className="text-faint" /> | |
| 174 | + | {commit.hash.slice(0, 7)} | |
| 175 | + | </Link> | |
| 176 | + | {checks} | |
| 177 | + | <TimeAgo at={commit.authoredAt} /> | |
| 178 | + | </span> | |
| 179 | + | )} | |
| 180 | + | </> | |
| 181 | + | )} | |
| 182 | + | </p> | |
| 183 | + | </> | |
| 184 | + | ) : canChange ? ( | |
| 185 | + | <> | |
| 186 | + | <p className="mt-2 text-lg font-medium">Where is production?</p> | |
| 187 | + | <p className="mt-1 max-w-lg text-sm text-muted"> | |
| 188 | + | {project.name} is deployed by its own pipeline. Give production's address, and this card links to it. | |
| 189 | + | </p> | |
| 190 | + | <AddressForm field="productionUrl" placeholder="https://example.com" label="Production's address" extra={{ intent: "kind", choice: "elsewhere" }} /> | |
| 191 | + | </> | |
| 192 | + | ) : ( | |
| 193 | + | <p className="mt-2 text-sm text-muted">Deployed by its own pipeline.</p> | |
| 194 | + | )} | |
| 195 | + | <DeployQuietly base={base} canDeploy={canDeploy} what="it" /> | |
| 196 | + | </div> | |
| 197 | + | {url && ( | |
| 198 | + | <div className="flex shrink-0 items-center gap-2"> | |
| 199 | + | <ButtonLink to={url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer"> | |
| 200 | + | Visit | |
| 201 | + | <ArrowUpRight size={14} /> | |
| 202 | + | </ButtonLink> | |
| 203 | + | </div> | |
| 189 | 204 | )} | |
| 190 | − | <DeployQuietly base={base} canDeploy={canDeploy} what="it" /> | |
| 191 | 205 | </div> | |
| 192 | − | {url && ( | |
| 193 | − | <div className="flex shrink-0 items-center gap-2"> | |
| 194 | − | <ButtonLink to={url} variant="accent" reloadDocument target="_blank" rel="noopener noreferrer"> | |
| 195 | − | Visit | |
| 196 | − | <ArrowUpRight size={14} /> | |
| 197 | − | </ButtonLink> | |
| 198 | − | </div> | |
| 199 | − | )} | |
| 200 | 206 | </div> | |
| 201 | 207 | ); | |
| 202 | 208 | } |
| 140 | 140 | return orderEnvironments(environments).find((env) => env.production_environment && env.latest != null) ?? null; | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | /** | |
| 144 | + | * Production deployed somewhere other than g1t.page (by g1t Actions, or | |
| 145 | + | * reported through the API), as an app deployed elsewhere shows it: the | |
| 146 | + | * production environment's latest deployment, when it is not a g1t.page | |
| 147 | + | * build. `created_at` is its latest status. | |
| 148 | + | */ | |
| 149 | + | export function reportedProduction(environments: readonly DeploymentEnvironment[]): { | |
| 150 | + | environment: string; | |
| 151 | + | url: string | null; | |
| 152 | + | state: string; | |
| 153 | + | sha: string; | |
| 154 | + | created_at: string; | |
| 155 | + | } | null { | |
| 156 | + | const reported = productionEnvironment(environments); | |
| 157 | + | if (!reported?.latest || reported.latest.source === "g1t_page") return null; | |
| 158 | + | return { | |
| 159 | + | environment: reported.name, | |
| 160 | + | url: environmentUrl(reported), | |
| 161 | + | state: reported.latest.state, | |
| 162 | + | sha: reported.latest.sha, | |
| 163 | + | created_at: reported.latest.updated_at, | |
| 164 | + | }; | |
| 165 | + | } | |
| 166 | + | ||
| 143 | 167 | /** The filters the list's address can carry, besides the page. */ | |
| 144 | 168 | export const FILTER_KEYS = ["environment", "state", "source", "creator", "ref"] as const; | |
| 145 | 169 | export type FilterKey = (typeof FILTER_KEYS)[number]; |
| 200 | 200 | assert.equal(await usercontentLimited(env, request(raw), raw), null); | |
| 201 | 201 | assert.equal((await usercontentLimited(env, request(raw), raw))?.status, 429); | |
| 202 | 202 | assert.equal(await usercontentLimited(env, request("/avatars/" + "a".repeat(64)), "/avatars/" + "a".repeat(64)), null); | |
| 203 | + | const image = "/production-images/" + "b".repeat(64); | |
| 204 | + | assert.equal(await usercontentLimited(env, request(image), image), null); | |
| 203 | 205 | assert.deepEqual(env.WEB_ANONYMOUS_LIMIT.keys, ["ip:203.0.113.9", "ip:203.0.113.9"]); | |
| 204 | 206 | }); |
| 120 | 120 | /** | |
| 121 | 121 | * The 429 for a repository file on the usercontent origin past its limit, | |
| 122 | 122 | * or null to go on. Nothing there is signed in (it never sees the session | |
| 123 | − | * cookie), so a file counts as a signed-out page from its address. Avatars | |
| 124 | − | * and custom emoji are answered from cache and are not limited. | |
| 123 | + | * cookie), so a file counts as a signed-out page from its address. Avatars, | |
| 124 | + | * custom emoji and production images are answered from cache and are not | |
| 125 | + | * limited. | |
| 125 | 126 | */ | |
| 126 | 127 | export async function usercontentLimited(env: FrontDoorLimits, request: Request, path: string): Promise<Response | null> { | |
| 127 | − | if (path.startsWith("/avatars/") || path.startsWith("/emoji/")) return null; | |
| 128 | + | if (path.startsWith("/avatars/") || path.startsWith("/emoji/") || path.startsWith("/production-images/")) return null; | |
| 128 | 129 | const verdict = await checkLimit(env.WEB_ANONYMOUS_LIMIT, `ip:${clientAddress(request)}`); | |
| 129 | 130 | return verdict === "limited" ? tooManyRequests(PAGE_MESSAGE) : null; | |
| 130 | 131 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | MAX_PRODUCTION_IMAGE_BYTES, | |
| 6 | + | cardSources, | |
| 7 | + | externalTarget, | |
| 8 | + | externalVersion, | |
| 9 | + | productionImageUrl, | |
| 10 | + | readProductionImageUpload, | |
| 11 | + | utcDay, | |
| 12 | + | } from "./production-image.ts"; | |
| 13 | + | ||
| 14 | + | const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]; | |
| 15 | + | const GIF = [...new TextEncoder().encode("GIF89a"), 0, 0, 0, 0, 0, 0]; | |
| 16 | + | ||
| 17 | + | function form(bytes: number[] | Uint8Array | null, type = "image/png"): FormData { | |
| 18 | + | const data = new FormData(); | |
| 19 | + | if (bytes) data.set("image", new File([Uint8Array.from(bytes)], "shot.png", { type })); | |
| 20 | + | return data; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | test("an uploaded image is served from the usercontent origin by its hash", () => { | |
| 24 | + | assert.equal(productionImageUrl("ab12", "https://g1tusercontent.com"), "https://g1tusercontent.com/production-images/ab12"); | |
| 25 | + | assert.equal(productionImageUrl("ab12"), "/production-images/ab12"); | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("an upload is a PNG, JPEG or WebP of at most 5 MB, read by its bytes", async () => { | |
| 29 | + | const ok = await readProductionImageUpload(form(PNG)); | |
| 30 | + | assert.ok("image" in ok); | |
| 31 | + | assert.deepEqual(await readProductionImageUpload(form(null)), { error: "Choose an image to upload." }); | |
| 32 | + | assert.deepEqual(await readProductionImageUpload(form(GIF, "image/gif")), { error: "Use a PNG, JPEG or WebP image." }); | |
| 33 | + | // Named a PNG, but it is not one. | |
| 34 | + | assert.deepEqual(await readProductionImageUpload(form([1, 2, 3, 4])), { error: "Use a PNG, JPEG or WebP image." }); | |
| 35 | + | const big = new Uint8Array(MAX_PRODUCTION_IMAGE_BYTES + 1); | |
| 36 | + | big.set(PNG); | |
| 37 | + | assert.deepEqual(await readProductionImageUpload(form(big)), { error: "Use an image of at most 5 MB." }); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | const now = Date.parse("2026-10-11T12:00:00Z"); | |
| 41 | + | const elsewhere = { kind: "app", runs: "elsewhere", productionUrl: "https://g1t.sh", productionImage: null }; | |
| 42 | + | const reported = { url: "https://app.example.com", sha: "ABC1234DEF5678ABC1234DEF5678ABC1234DEF56", created_at: "2026-10-11T10:00:00Z" }; | |
| 43 | + | ||
| 44 | + | test("production elsewhere is taken at its address, versioned by the day", () => { | |
| 45 | + | assert.deepEqual(externalTarget(elsewhere, null, now), { url: "https://g1t.sh", commit: "2026-10-11", since: null }); | |
| 46 | + | assert.equal(utcDay(now), "2026-10-11"); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("a reported production deployment gives the address and the commit", () => { | |
| 50 | + | assert.deepEqual(externalTarget(elsewhere, reported, now), { | |
| 51 | + | url: "https://app.example.com", | |
| 52 | + | commit: reported.sha.toLowerCase(), | |
| 53 | + | since: reported.created_at, | |
| 54 | + | }); | |
| 55 | + | // No address of its own: the project's. | |
| 56 | + | assert.equal(externalTarget(elsewhere, { ...reported, url: null }, now)?.url, "https://g1t.sh"); | |
| 57 | + | // An app nobody said runs where, reporting production, runs elsewhere. | |
| 58 | + | assert.equal(externalTarget({ ...elsewhere, runs: null, productionUrl: null }, reported, now)?.url, "https://app.example.com"); | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test("no screenshot when it is not elsewhere, has no address, or shows an upload", () => { | |
| 62 | + | assert.equal(externalTarget({ ...elsewhere, runs: "g1t" }, null, now), null); | |
| 63 | + | assert.equal(externalTarget({ ...elsewhere, runs: null }, null, now), null); | |
| 64 | + | assert.equal(externalTarget({ ...elsewhere, runs: null, kind: "library" }, reported, now), null); | |
| 65 | + | assert.equal(externalTarget({ ...elsewhere, productionUrl: null }, null, now), null); | |
| 66 | + | assert.equal(externalTarget({ ...elsewhere, productionUrl: "ftp://g1t.sh" }, null, now), null); | |
| 67 | + | assert.equal(externalTarget({ ...elsewhere, productionImage: { preferred: true } }, reported, now), null); | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | test("a version changes with the deploy or the day", () => { | |
| 71 | + | assert.equal(externalVersion({ url: "https://g1t.sh", commit: "2026-10-11", since: null }), "2026-10-11-0"); | |
| 72 | + | assert.equal( | |
| 73 | + | externalVersion({ url: "https://g1t.sh", commit: "abc1234", since: "2026-10-11T10:00:00Z" }), | |
| 74 | + | `abc1234-${Date.parse("2026-10-11T10:00:00Z")}`, | |
| 75 | + | ); | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test("the card tries the screenshot, then the upload; a preferred upload alone", () => { | |
| 79 | + | const upload = { url: "https://g1tusercontent.com/production-images/ab", preferred: false }; | |
| 80 | + | const shot = { src: "/a/b/production.jpg", kind: "screenshot" }; | |
| 81 | + | const uploaded = { src: upload.url, kind: "upload" }; | |
| 82 | + | assert.deepEqual(cardSources("/a/b/production.jpg", upload), [shot, uploaded]); | |
| 83 | + | assert.deepEqual(cardSources("/a/b/production.jpg", { ...upload, preferred: true }), [uploaded]); | |
| 84 | + | assert.deepEqual(cardSources(null, upload), [uploaded]); | |
| 85 | + | assert.deepEqual(cardSources("/a/b/production.jpg", null), [shot]); | |
| 86 | + | assert.deepEqual(cardSources(null, null), []); | |
| 87 | + | }); |
| 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" }; |
| 7 | 7 | * <usercontent>/<owner>/<repo>/raw/<ref>/<path> a file at a branch, tag or commit | |
| 8 | 8 | * <usercontent>/avatars/<sha256> an uploaded avatar | |
| 9 | 9 | * <usercontent>/emoji/<sha256> a workspace's custom emoji | |
| 10 | + | * <usercontent>/production-images/<sha256> a project's uploaded production image | |
| 10 | 11 | * | |
| 11 | 12 | * A public repository's files are there for anyone. A private one's carry | |
| 12 | 13 | * `?token=`, a signature the site makes for someone who can read the |
| 283 | 283 | route(":owner/:repo/add-ci", "routes/repo/add-ci.ts"), | |
| 284 | 284 | // A screenshot of a project's production, for its overview. | |
| 285 | 285 | route(":owner/:repo/production.jpg", "routes/repo/production-screenshot.ts"), | |
| 286 | + | // The same, of production deployed elsewhere, at its address. | |
| 287 | + | route(":owner/:repo/production-elsewhere.jpg", "routes/repo/production-elsewhere.ts"), | |
| 286 | 288 | // A file as it is, sent on to the usercontent origin (lib/usercontent.ts). | |
| 287 | 289 | route(":owner/:repo/raw/:ref/*", "routes/repo/raw.ts"), | |
| 288 | 290 | // A project: its overview first, its repository's code under Code. The |
| 102 | 102 | import { accessTo, countsFor, projectFor, refusal, repoFor, requireWritableRepo } from "../../lib/access.server"; | |
| 103 | 103 | import { shotVersion } from "./production-screenshot"; | |
| 104 | 104 | import { DeploymentsPanel } from "../../components/deployments-panel"; | |
| 105 | − | import { environmentUrl, productionEnvironment } from "../../lib/deployments"; | |
| 105 | + | import { reportedProduction } from "../../lib/deployments"; | |
| 106 | + | import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image"; | |
| 106 | 107 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 107 | 108 | import { readBranches } from "../../lib/branches.server"; | |
| 108 | 109 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| ⋯ | |||
| 372 | 373 | // reported through the API): the slot an app deployed elsewhere shows. | |
| 373 | 374 | const environments = await environmentsP; | |
| 374 | 375 | const environmentList = environments?.ok ? environments.value : null; | |
| 375 | − | const reported = productionEnvironment(environmentList?.environments ?? []); | |
| 376 | − | const deployment: ExternalDeployment | null = | |
| 377 | − | reported?.latest && reported.latest.source !== "g1t_page" | |
| 378 | − | ? { | |
| 379 | − | environment: reported.name, | |
| 380 | − | url: environmentUrl(reported), | |
| 381 | − | state: reported.latest.state, | |
| 382 | − | sha: reported.latest.sha, | |
| 383 | − | created_at: reported.latest.updated_at, | |
| 384 | − | } | |
| 385 | − | : null; | |
| 376 | + | const deployment: ExternalDeployment | null = reportedProduction(environmentList?.environments ?? []); | |
| 386 | 377 | // An app that has not said where it runs, but reports production from | |
| 387 | 378 | // elsewhere, runs elsewhere. | |
| 388 | 379 | const found = ok(project); | |
| ⋯ | |||
| 452 | 443 | checklistTitle: plan.title, | |
| 453 | 444 | // Production as reported from outside g1t, for an app deployed elsewhere. | |
| 454 | 445 | deployment, | |
| 446 | + | // Its screenshot's address on the site (production-elsewhere.ts), when it has one to show. | |
| 447 | + | elsewhereShot: elsewhereShot(projectValue, deployment, base), | |
| 455 | 448 | // Every environment's latest deployment, for the Deployments panel. | |
| 456 | 449 | environments: environmentList, | |
| 457 | 450 | // About's languages and contributors, filled in by the files' About work. | |
| ⋯ | |||
| 697 | 690 | ); | |
| 698 | 691 | } | |
| 699 | 692 | ||
| 693 | + | /** | |
| 694 | + | * Where the overview asks for the screenshot of production deployed | |
| 695 | + | * elsewhere, with `?v=` naming the reported deploy or the day; null when | |
| 696 | + | * there is none to show (lib/production-image.ts `externalTarget`). | |
| 697 | + | */ | |
| 698 | + | function elsewhereShot(project: Project | null | undefined, deployment: ExternalDeployment | null, base: string): string | null { | |
| 699 | + | const target = project ? externalTarget(project, deployment, Date.now()) : null; | |
| 700 | + | return target ? `${base}/production-elsewhere.jpg?v=${externalVersion(target)}` : null; | |
| 701 | + | } | |
| 702 | + | ||
| 700 | 703 | function Stat({ label, value, to }: { label: string; value: ReactNode; to?: string }) { | |
| 701 | 704 | const body = ( | |
| 702 | 705 | <> | |
| ⋯ | |||
| 814 | 817 | : null; | |
| 815 | 818 | const stripLinks = project ? linksToShow({ ...project.links, custom: [] }, [headUrl]) : []; | |
| 816 | 819 | const addresses = useAddresses(); | |
| 820 | + | // The production card's picture: an uploaded image, served from the | |
| 821 | + | // usercontent origin, and where someone who may change it adds one. | |
| 822 | + | const upload = project?.productionImage | |
| 823 | + | ? { url: productionImageUrl(project.productionImage.file, addresses.usercontent), preferred: project.productionImage.preferred } | |
| 824 | + | : null; | |
| 825 | + | const addImage = canChange ? `${base}/settings#production-image` : null; | |
| 826 | + | const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null; | |
| 817 | 827 | const source = project?.source.kind === "hosted" ? project.source : null; | |
| 818 | 828 | const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0; | |
| 819 | 829 | useLiveRefresh(moving); | |
| ⋯ | |||
| 876 | 886 | deployment={loaderData.deployment} | |
| 877 | 887 | commit={commit} | |
| 878 | 888 | checks={commit ? <CommitChecksBadge checks={checks} sha={commit.hash} className="size-5" /> : undefined} | |
| 889 | + | shot={ | |
| 890 | + | <ProductionShot | |
| 891 | + | sources={cardSources(loaderData.elsewhereShot, upload)} | |
| 892 | + | href={elsewhereUrl} | |
| 893 | + | label={elsewhereUrl ? host(elsewhereUrl).replace(/\/$/, "") : ""} | |
| 894 | + | addImage={addImage} | |
| 895 | + | className="w-full shrink-0 sm:w-60 lg:w-72" | |
| 896 | + | /> | |
| 897 | + | } | |
| 879 | 898 | /> | |
| 880 | 899 | ) : plan === "unknown" && project ? ( | |
| 881 | 900 | <WhereItRuns project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} /> | |
| ⋯ | |||
| 887 | 906 | <LibraryHead library={library} project={project} base={base} workspace={params.owner} /> | |
| 888 | 907 | ) : ( | |
| 889 | 908 | <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6"> | |
| 890 | − | {member && (production || settings?.enabled) && ( | |
| 891 | − | <ProductionShot | |
| 892 | − | src={production ? `${base}/production.jpg?v=${shotVersion(production)}` : null} | |
| 893 | − | href={production ? (productionUrl ?? production.url) : null} | |
| 894 | − | label={host(production ? (productionUrl ?? production.url) : (settings?.productionUrl ?? ""))} | |
| 895 | − | className="w-full shrink-0 sm:w-60 lg:w-72" | |
| 896 | − | /> | |
| 897 | − | )} | |
| 909 | + | {/* Always a picture: the screenshot (members, like the deployments), the uploaded image, or the frame. */} | |
| 910 | + | <ProductionShot | |
| 911 | + | sources={cardSources(member && production ? `${base}/production.jpg?v=${shotVersion(production)}` : null, upload)} | |
| 912 | + | href={production ? (productionUrl ?? production.url) : null} | |
| 913 | + | label={production ? host(productionUrl ?? production.url) : settings?.productionUrl ? host(settings.productionUrl) : ""} | |
| 914 | + | note={!production && settings?.enabled ? "A screenshot appears after the next deploy" : null} | |
| 915 | + | addImage={addImage} | |
| 916 | + | className="w-full shrink-0 sm:w-60 lg:w-72" | |
| 917 | + | /> | |
| 898 | 918 | <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4"> | |
| 899 | 919 | <div className="min-w-0"> | |
| 900 | 920 | <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase"> | |
| 1 | + | /** | |
| 2 | + | * A screenshot of a project's production deployed elsewhere, for its | |
| 3 | + | * overview: of the address its reported production deployment gives, else | |
| 4 | + | * the one set for it (lib/production-image.ts `externalTarget`). The | |
| 5 | + | * address is always the project's own, never one the request names, and | |
| 6 | + | * the og service visits only public ones. For anyone who can see the | |
| 7 | + | * project: it shows a public address. Kept by the browser for an hour; the | |
| 8 | + | * overview's `?v=` changes with each reported deploy and each day. | |
| 9 | + | */ | |
| 10 | + | import { env } from "cloudflare:workers"; | |
| 11 | + | ||
| 12 | + | import type { Route } from "./+types/production-elsewhere"; | |
| 13 | + | import { reportedProduction } from "../../lib/deployments"; | |
| 14 | + | import { externalTarget } from "../../lib/production-image"; | |
| 15 | + | import { deployments } from "../../lib/services.server"; | |
| 16 | + | import { getViewer } from "../../lib/session.server"; | |
| 17 | + | import { projectFor } from "../../lib/access.server"; | |
| 18 | + | ||
| 19 | + | const HOUR = "private, max-age=3600"; | |
| 20 | + | const BRIEF = "private, max-age=60"; | |
| 21 | + | ||
| 22 | + | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 23 | + | const viewer = getViewer(context); | |
| 24 | + | const none = (status: number) => new Response(null, { status, headers: { "cache-control": BRIEF } }); | |
| 25 | + | if (!env.SCREENSHOTS) return none(404); | |
| 26 | + | const project = await projectFor(context, params).catch(() => null); | |
| 27 | + | if (!project?.ok) return none(404); | |
| 28 | + | const repo = project.value.source.kind === "hosted" ? project.value.source.repo : { namespace: params.owner, name: params.repo }; | |
| 29 | + | const environments = await deployments.environments(repo, viewer).catch(() => null); | |
| 30 | + | const reported = reportedProduction(environments?.ok ? environments.value.environments : []); | |
| 31 | + | const target = externalTarget(project.value, reported, Date.now()); | |
| 32 | + | if (!target) return none(404); | |
| 33 | + | const shot = await env.SCREENSHOTS.externalImage({ url: target.url, commit: target.commit, since: target.since ?? undefined }).catch( | |
| 34 | + | (error: unknown) => (console.warn("screenshot:", error), null), | |
| 35 | + | ); | |
| 36 | + | if (!shot) return none(404); | |
| 37 | + | return new Response(shot.body, { | |
| 38 | + | headers: { | |
| 39 | + | "content-type": shot.contentType, | |
| 40 | + | "cache-control": HOUR, | |
| 41 | + | "x-content-type-options": "nosniff", | |
| 42 | + | // A picture of someone's site: shown, never run. | |
| 43 | + | "content-security-policy": "default-src 'none'; sandbox", | |
| 44 | + | "last-modified": new Date(shot.capturedAt).toUTCString(), | |
| 45 | + | }, | |
| 46 | + | }); | |
| 47 | + | } |
| 1 | − | import { Box, Code2, GitBranch, Link2, Rocket } from "lucide-react"; | |
| 1 | + | import { Box, Code2, GitBranch, Image as ImageIcon, Link2, Rocket } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | 3 | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/settings"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | + | import { useAddresses } from "../../lib/addresses"; | |
| 8 | + | import { productionImageUrl, readProductionImageUpload } from "../../lib/production-image"; | |
| 7 | 9 | import { KindFields, LinkFields } from "../../components/project-about"; | |
| 10 | + | import { ProductionImageField } from "../../components/production-image-field"; | |
| 8 | 11 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 9 | 12 | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 10 | 13 | import { Card } from "../../components/ui/card"; | |
| ⋯ | |||
| 42 | 45 | const user = requireUser(context, request); | |
| 43 | 46 | await requireCapability(context, params, "manage_settings"); | |
| 44 | 47 | const form = await request.formData(); | |
| 48 | + | // Production's picture on the overview: whoever may change its address may change it. | |
| 49 | + | const intent = form.get("intent"); | |
| 50 | + | if (intent === "production-image" || intent === "remove-production-image" || intent === "prefer-production-image") { | |
| 51 | + | let change: { image?: string | null; preferred?: boolean }; | |
| 52 | + | if (intent === "production-image") { | |
| 53 | + | const upload = await readProductionImageUpload(form); | |
| 54 | + | if ("error" in upload) return { error: upload.error }; | |
| 55 | + | change = { image: upload.image }; | |
| 56 | + | } else { | |
| 57 | + | change = intent === "remove-production-image" ? { image: null } : { preferred: form.get("preferred") === "on" }; | |
| 58 | + | } | |
| 59 | + | const saved = await projects.setProductionImage(user, params.owner, params.repo, change); | |
| 60 | + | return saved.ok ? { imageSaved: true as const } : { error: saved.error.message }; | |
| 61 | + | } | |
| 45 | 62 | const choice = form.get("choice") as KindChoice | null; | |
| 46 | 63 | const set = choice && choice in KIND_CHOICE_SETS ? KIND_CHOICE_SETS[choice] : null; | |
| 47 | 64 | const saved = await projects.update(user, params.owner, params.repo, { | |
| ⋯ | |||
| 67 | 84 | const blocked = stops && !neverDeploys(project) && deploymentsOn === true; | |
| 68 | 85 | const base = `/${params.owner}/${params.repo}`; | |
| 69 | 86 | const source = project.source.kind === "hosted" ? project.source : null; | |
| 87 | + | const { usercontent } = useAddresses(); | |
| 70 | 88 | return ( | |
| 71 | 89 | <div className="max-w-4xl"> | |
| 72 | 90 | <RepoSettingsHeading base={base} /> | |
| ⋯ | |||
| 210 | 228 | </div> | |
| 211 | 229 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 212 | 230 | </Form> | |
| 231 | + | ||
| 232 | + | {/* Its own forms, sent as soon as an image is picked: outside the settings form. */} | |
| 233 | + | <section className="mt-10 scroll-mt-20 space-y-4" id="production-image"> | |
| 234 | + | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 235 | + | <ImageIcon size={15} className="text-accent" /> | |
| 236 | + | Production image | |
| 237 | + | </h2> | |
| 238 | + | <p className="max-w-2xl text-xs text-muted"> | |
| 239 | + | The picture on its overview's production card. A screenshot is taken by itself for an app deployed on g1t, | |
| 240 | + | and of production's address for one deployed elsewhere, about once a day and after each production deployment | |
| 241 | + | reported to g1t. | |
| 242 | + | </p> | |
| 243 | + | <ProductionImageField | |
| 244 | + | image={project.productionImage} | |
| 245 | + | src={project.productionImage ? productionImageUrl(project.productionImage.file, usercontent) : null} | |
| 246 | + | screenshots={project.runs === "g1t" || (project.runs === "elsewhere" && !!project.productionUrl)} | |
| 247 | + | /> | |
| 248 | + | </section> | |
| 213 | 249 | </div> | |
| 214 | 250 | ); | |
| 215 | 251 | } | |
| 45 | 45 | commit: string; | |
| 46 | 46 | capturedAt: string; | |
| 47 | 47 | } | null>; | |
| 48 | + | /** A production deployed elsewhere, at its address; `commit` is a deploy's commit or a UTC day. */ | |
| 49 | + | externalImage(input: { url: string; commit: string; since?: string }): Promise<{ | |
| 50 | + | body: ArrayBuffer; | |
| 51 | + | contentType: string; | |
| 52 | + | commit: string; | |
| 53 | + | capturedAt: string; | |
| 54 | + | } | null>; | |
| 48 | 55 | }; | |
| 49 | 56 | BLOBS: KVNamespace; | |
| 50 | 57 | /** Uploaded avatars by SHA-256, with `{ contentType }`; written by identity. */ |
| 12 | 12 | export const AVATAR_PATH = /^\/avatars\/([0-9a-f]{64})$/; | |
| 13 | 13 | /** A workspace's custom emoji, by the SHA-256 of its bytes: kept by chat under `emoji/<hash>` in the same namespace. */ | |
| 14 | 14 | export const EMOJI_PATH = /^\/emoji\/([0-9a-f]{64})$/; | |
| 15 | + | /** A project's uploaded production image, by the SHA-256 of its bytes: kept by projects under `production/<hash>` in the same namespace. */ | |
| 16 | + | export const PRODUCTION_IMAGE_PATH = /^\/production-images\/([0-9a-f]{64})$/; | |
| 15 | 17 | /** A file put in a Docs page, by its random key: kept by the artifacts service (services/artifacts). */ | |
| 16 | 18 | export const DOCS_FILE_PATH = /^\/docs-files\/([0-9a-f]{64})$/; | |
| 17 | 19 | /** The only types identity stores, having checked each image's bytes. */ | |
| ⋯ | |||
| 42 | 44 | if (avatar) return serveAvatar(env, ctx, method, avatar[1]!, new URL(request.url).origin); | |
| 43 | 45 | const emoji = EMOJI_PATH.exec(path); | |
| 44 | 46 | if (emoji) return serveAvatar(env, ctx, method, `emoji/${emoji[1]!}`, new URL(request.url).origin); | |
| 47 | + | const production = PRODUCTION_IMAGE_PATH.exec(path); | |
| 48 | + | if (production) return serveAvatar(env, ctx, method, `production/${production[1]!}`, new URL(request.url).origin); | |
| 45 | 49 | const docsFile = DOCS_FILE_PATH.exec(path); | |
| 46 | 50 | if (docsFile) return serveDocsFile(env, method, docsFile[1]!); | |
| 47 | 51 | const file = parseRawPath(path); | |
| ⋯ | |||
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | 90 | /** | |
| 87 | − | * An uploaded avatar, or a custom emoji (`key` is then `emoji/<hash>`). Its | |
| 91 | + | * An uploaded avatar, a custom emoji (`key` is then `emoji/<hash>`) or a | |
| 92 | + | * production image (`production/<hash>`). Its | |
| 88 | 93 | * address is its hash, so it never changes and is | |
| 89 | 94 | * kept for good: each data centre keeps it in its cache after the first | |
| 90 | 95 | * view, and storage is read about once per place, not once per visitor. | |
| ⋯ | |||
| 94 | 99 | async function serveAvatar(env: Env, ctx: ExecutionContext, method: string, key: string, origin: string): Promise<Response> { | |
| 95 | 100 | // The Workers runtime's own cache, which the DOM types do not know. | |
| 96 | 101 | const cache = (caches as unknown as { default: Cache }).default; | |
| 97 | − | const cacheKey = new Request(`${origin}/${key.startsWith("emoji/") ? key : `avatars/${key}`}`, { method: "GET" }); | |
| 102 | + | const path = key.startsWith("emoji/") ? key : key.startsWith("production/") ? `production-images/${key.slice("production/".length)}` : `avatars/${key}`; | |
| 103 | + | const cacheKey = new Request(`${origin}/${path}`, { method: "GET" }); | |
| 98 | 104 | const cached = await cache.match(cacheKey); | |
| 99 | 105 | if (cached) { | |
| 100 | 106 | return method === "HEAD" ? new Response(null, { headers: cached.headers }) : cached; | |
| 107 | 107 | ], | |
| 108 | 108 | ), | |
| 109 | 109 | // Projects follow their repositories, and wake on activity | |
| 110 | − | // (projects/src/index.ts, activity.ts). | |
| 110 | + | // (projects/src/index.ts, activity.ts). A production deployment | |
| 111 | + | // reported from elsewhere asks for its screenshot. | |
| 111 | 112 | ( | |
| 112 | 113 | "SUBSCRIBER_PROJECTS", | |
| 113 | 114 | &[ | |
| ⋯ | |||
| 124 | 125 | "comment.created", | |
| 125 | 126 | "deployment.succeeded", | |
| 126 | 127 | "deployment.failed", | |
| 128 | + | "deployment_status.created", | |
| 127 | 129 | "package.published", | |
| 128 | 130 | "package.deleted", | |
| 129 | 131 | "package.version_deleted", | |
| ⋯ | |||
| 330 | 332 | assert!(routed("SUBSCRIBER_AGENTS", "workspace.member_joined")); | |
| 331 | 333 | assert!(!routed("SUBSCRIBER_AGENTS", "comment.created")); | |
| 332 | 334 | assert!(!routed("SUBSCRIBER_AGENTS", "workspace.updated")); | |
| 335 | + | assert!(routed("SUBSCRIBER_PROJECTS", "deployment_status.created")); | |
| 336 | + | assert!(!routed("SUBSCRIBER_PROJECTS", "deployment.created")); | |
| 333 | 337 | // Every subscriber follows what moves or removes a repository. | |
| 334 | 338 | for (binding, _) in ROUTES { | |
| 335 | 339 | for kind in LIFECYCLE { | |
| 960 | 960 | byRepo: (repoId) => call("by_repo", { repoId }), | |
| 961 | 961 | create: (actor, workspace, input) => call("create", { actor, workspace, input }), | |
| 962 | 962 | update: (actor, workspace, slug, changes) => call("update", { actor, workspace, slug, changes }), | |
| 963 | + | setProductionImage: (actor, workspace, slug, change) => call("set_production_image", { actor, workspace, slug, change }), | |
| 964 | + | externalProductions: () => call("external_productions", {}), | |
| 963 | 965 | deploymentsChanged: (projectId, enabled) => call("deployments_changed", { projectId, enabled }), | |
| 964 | 966 | shortcuts: (workspace, viewer) => call("shortcuts", { workspace, viewer }), | |
| 965 | 967 | pin: (actor, workspace, slug, position) => call("pin", { actor, workspace, slug, position: position ?? null }), |
| 51 | 51 | runs: ProjectRuns | null; | |
| 52 | 52 | /** Where production is when it runs elsewhere; null when nobody has given one. */ | |
| 53 | 53 | productionUrl: string | null; | |
| 54 | + | /** | |
| 55 | + | * An image of production someone uploaded, shown on its overview when | |
| 56 | + | * there is no screenshot, or instead of one when `preferred`. Null when | |
| 57 | + | * none was uploaded. | |
| 58 | + | */ | |
| 59 | + | productionImage: ProductionImage | null; | |
| 54 | 60 | /** What detection decides, whatever the setting is, to show beside it. */ | |
| 55 | 61 | detected: { kind: ProjectKind; reason: KindReason }; | |
| 56 | 62 | /** The ecosystem its files say it publishes to, for how to publish; null when none says. */ | |
| ⋯ | |||
| 102 | 108 | custom: ProjectLink[]; | |
| 103 | 109 | }; | |
| 104 | 110 | ||
| 111 | + | /** | |
| 112 | + | * An uploaded image of a project's production. `file` is the SHA-256 of | |
| 113 | + | * its bytes: it is served from the usercontent origin at | |
| 114 | + | * `/production-images/<file>`. | |
| 115 | + | */ | |
| 116 | + | export type ProductionImage = { file: string; contentType: string; preferred: boolean }; | |
| 117 | + | ||
| 118 | + | /** The largest production image taken, in bytes. */ | |
| 119 | + | export const MAX_PRODUCTION_IMAGE_BYTES = 5 * 1024 * 1024; | |
| 120 | + | ||
| 121 | + | /** The types a production image may be, read from its bytes. */ | |
| 122 | + | export const PRODUCTION_IMAGE_TYPES: readonly string[] = ["image/png", "image/jpeg", "image/webp"]; | |
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * A change to a project's production image. `image` is the new one's bytes | |
| 126 | + | * in base64, or null to remove it (and with it the preference); left out, | |
| 127 | + | * it stays. `preferred` shows it instead of a screenshot. | |
| 128 | + | */ | |
| 129 | + | export type ProductionImageChange = { image?: string | null; preferred?: boolean }; | |
| 130 | + | ||
| 105 | 131 | /** How many links of its own a project keeps besides its homepage and docs. */ | |
| 106 | 132 | export const MAX_PROJECT_LINKS = 10; | |
| 107 | 133 | /** The longest link label. */ | |
| ⋯ | |||
| 169 | 195 | * is refused: they are turned off first. | |
| 170 | 196 | */ | |
| 171 | 197 | update(actor: User, workspace: string, slug: string, changes: ProjectChanges): Promise<Result<Project>>; | |
| 198 | + | /** | |
| 199 | + | * Uploads, replaces or removes its production image, or says whether it | |
| 200 | + | * is shown instead of a screenshot. Whoever may change the project's | |
| 201 | + | * settings, as for its production address. | |
| 202 | + | */ | |
| 203 | + | setProductionImage(actor: User, workspace: string, slug: string, change: ProductionImageChange): Promise<Result<Project>>; | |
| 204 | + | /** | |
| 205 | + | * For the screenshot service's daily refresh: every production address | |
| 206 | + | * of a project deployed elsewhere that shows a screenshot (not an | |
| 207 | + | * uploaded image instead), once each. | |
| 208 | + | */ | |
| 209 | + | externalProductions(): Promise<string[]>; | |
| 172 | 210 | /** For deployments: Deployments were turned on or off for the project. */ | |
| 173 | 211 | deploymentsChanged(projectId: string, enabled: boolean): Promise<void>; | |
| 174 | 212 | /** A person's pinned and recent projects in a workspace. Empty for anyone else. */ | |
| 1 | 1 | /** | |
| 2 | 2 | * Taking and keeping production screenshots with Browser Rendering. | |
| 3 | 3 | * | |
| 4 | − | * A screenshot is a few seconds of browser time: Workers Paid includes 10 | |
| 5 | − | * browser hours a month and charges $0.09 an hour after that, so one costs | |
| 6 | − | * about $0.0001, and each production deploy takes at most one (plus a retry | |
| 7 | − | * after `RETRY_AFTER_MS` if it failed). That is small enough not to meter. | |
| 4 | + | * A screenshot is a few seconds of browser time (at most `SETTLE_MS` and | |
| 5 | + | * the shot itself): Workers Paid includes 10 browser hours a month and | |
| 6 | + | * charges $0.09 an hour after that, so one costs about $0.0001-$0.0003. | |
| 7 | + | * Each production deploy on g1t.page takes at most one, plus a retry after | |
| 8 | + | * `RETRY_AFTER_MS` if it failed. A production deployed elsewhere takes one | |
| 9 | + | * when its address is set or changed and when a production deployment is | |
| 10 | + | * reported for it, and otherwise at most one a day, from the daily cron | |
| 11 | + | * (at most `EXTERNAL_PER_RUN` a run): a hundred such projects come to | |
| 12 | + | * about 3,000 shots and 4 to 10 browser hours a month. A project that | |
| 13 | + | * shows an uploaded image instead is not visited at all. That is small | |
| 14 | + | * enough not to meter. | |
| 8 | 15 | */ | |
| 9 | 16 | import puppeteer from "@cloudflare/puppeteer"; | |
| 10 | 17 | ||
| 11 | − | import { SETTLE_MS, type ShotRequest, VIEWPORT, attemptKey, expired, isCurrent, shotKey, shouldAttempt } from "./screenshot.ts"; | |
| 18 | + | import { | |
| 19 | + | type ExternalShot, | |
| 20 | + | SETTLE_MS, | |
| 21 | + | type ShotRequest, | |
| 22 | + | VIEWPORT, | |
| 23 | + | attemptKey, | |
| 24 | + | dayOf, | |
| 25 | + | dueForRefresh, | |
| 26 | + | expired, | |
| 27 | + | externalAttemptKey, | |
| 28 | + | externalShotKey, | |
| 29 | + | isCurrent, | |
| 30 | + | isFresh, | |
| 31 | + | parseExternalShot, | |
| 32 | + | shotKey, | |
| 33 | + | shouldAttempt, | |
| 34 | + | } from "./screenshot.ts"; | |
| 12 | 35 | ||
| 13 | 36 | export type ShotEnv = { | |
| 14 | 37 | BROWSER: Fetcher; | |
| ⋯ | |||
| 17 | 40 | ||
| 18 | 41 | export type Shot = { body: ArrayBuffer; contentType: string; commit: string; capturedAt: string }; | |
| 19 | 42 | ||
| 20 | − | /** The kept screenshot, whatever commit it shows. */ | |
| 21 | − | export async function stored(env: ShotEnv, host: string): Promise<Shot | null> { | |
| 22 | − | const object = await env.SCREENSHOTS.get(shotKey(host)); | |
| 43 | + | /** Where one screenshot is kept and its attempts noted, and the page it shows. */ | |
| 44 | + | type Target = { key: string; attempt: string; url: string; host: string; commit: string }; | |
| 45 | + | ||
| 46 | + | async function read(env: ShotEnv, key: string): Promise<Shot | null> { | |
| 47 | + | const object = await env.SCREENSHOTS.get(key); | |
| 23 | 48 | if (!object) return null; | |
| 24 | 49 | return { | |
| 25 | 50 | body: await object.arrayBuffer(), | |
| ⋯ | |||
| 29 | 54 | }; | |
| 30 | 55 | } | |
| 31 | 56 | ||
| 32 | − | /** | |
| 33 | − | * Takes the screenshot of `commit`, unless one was tried for it moments | |
| 34 | − | * ago. Returns it, or null when it was not taken. | |
| 35 | − | */ | |
| 36 | − | export async function take(env: ShotEnv, { host, commit }: ShotRequest): Promise<Shot | null> { | |
| 37 | − | const marker = await env.SCREENSHOTS.head(attemptKey(host)); | |
| 38 | − | if (!shouldAttempt(marker?.customMetadata, commit, Date.now())) return null; | |
| 57 | + | /** The kept screenshot, whatever commit it shows. */ | |
| 58 | + | export function stored(env: ShotEnv, host: string): Promise<Shot | null> { | |
| 59 | + | return read(env, shotKey(host)); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** The kept screenshot of a production deployed elsewhere, by its host. */ | |
| 63 | + | export function storedExternal(env: ShotEnv, host: string): Promise<Shot | null> { | |
| 64 | + | return read(env, externalShotKey(host)); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /** Takes the target's screenshot, unless one was tried for its commit moments ago. */ | |
| 68 | + | async function shoot(env: ShotEnv, target: Target): Promise<Shot | null> { | |
| 69 | + | const marker = await env.SCREENSHOTS.head(target.attempt); | |
| 70 | + | if (!shouldAttempt(marker?.customMetadata, target.commit, Date.now())) return null; | |
| 39 | 71 | const at = new Date().toISOString(); | |
| 40 | − | await env.SCREENSHOTS.put(attemptKey(host), "", { customMetadata: { commit, at } }); | |
| 72 | + | await env.SCREENSHOTS.put(target.attempt, "", { customMetadata: { commit: target.commit, at } }); | |
| 41 | 73 | ||
| 42 | 74 | let browser: Awaited<ReturnType<typeof puppeteer.launch>> | null = null; | |
| 43 | 75 | try { | |
| ⋯ | |||
| 45 | 77 | const page = await browser.newPage(); | |
| 46 | 78 | await page.setViewport(VIEWPORT); | |
| 47 | 79 | // A page that keeps the network busy is taken as it is when time is up. | |
| 48 | − | await page.goto(`https://${host}/`, { waitUntil: "networkidle2", timeout: SETTLE_MS }).catch((error: unknown) => { | |
| 49 | − | console.warn("og: page did not settle", host, String(error)); | |
| 80 | + | await page.goto(target.url, { waitUntil: "networkidle2", timeout: SETTLE_MS }).catch((error: unknown) => { | |
| 81 | + | console.warn("og: page did not settle", target.host, String(error)); | |
| 50 | 82 | }); | |
| 51 | 83 | const image = (await page.screenshot({ type: "jpeg", quality: 80 })) as Uint8Array; | |
| 52 | 84 | const body = image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength) as ArrayBuffer; | |
| 53 | 85 | const capturedAt = new Date().toISOString(); | |
| 54 | − | await env.SCREENSHOTS.put(shotKey(host), body, { | |
| 86 | + | await env.SCREENSHOTS.put(target.key, body, { | |
| 55 | 87 | httpMetadata: { contentType: "image/jpeg" }, | |
| 56 | − | customMetadata: { commit, capturedAt }, | |
| 88 | + | customMetadata: { commit: target.commit, capturedAt }, | |
| 57 | 89 | }); | |
| 58 | − | return { body, contentType: "image/jpeg", commit, capturedAt }; | |
| 90 | + | return { body, contentType: "image/jpeg", commit: target.commit, capturedAt }; | |
| 59 | 91 | } catch (error) { | |
| 60 | − | console.error("og: screenshot failed", host, error); | |
| 92 | + | console.error("og: screenshot failed", target.host, error); | |
| 61 | 93 | return null; | |
| 62 | 94 | } finally { | |
| 63 | 95 | await browser?.close().catch(() => undefined); | |
| 64 | 96 | } | |
| 65 | 97 | } | |
| 66 | 98 | ||
| 99 | + | /** | |
| 100 | + | * Takes the screenshot of `commit` of an app on g1t.page, unless one was | |
| 101 | + | * tried for it moments ago. Returns it, or null when it was not taken. | |
| 102 | + | */ | |
| 103 | + | export function take(env: ShotEnv, { host, commit }: ShotRequest): Promise<Shot | null> { | |
| 104 | + | return shoot(env, { key: shotKey(host), attempt: attemptKey(host), url: `https://${host}/`, host, commit }); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /** Takes the screenshot of a production deployed elsewhere, at its address. */ | |
| 108 | + | export function takeExternal(env: ShotEnv, { host, url, commit }: ExternalShot): Promise<Shot | null> { | |
| 109 | + | return shoot(env, { key: externalShotKey(host), attempt: externalAttemptKey(host), url, host, commit }); | |
| 110 | + | } | |
| 111 | + | ||
| 67 | 112 | /** The screenshot of the request's deploy, taken now if it has not been; else the last one kept. */ | |
| 68 | 113 | export async function screenshotOf(env: ShotEnv, request: ShotRequest): Promise<Shot | null> { | |
| 69 | 114 | const kept = await stored(env, request.host); | |
| ⋯ | |||
| 72 | 117 | } | |
| 73 | 118 | ||
| 74 | 119 | /** | |
| 120 | + | * The screenshot of a production deployed elsewhere: the one kept, with | |
| 121 | + | * `stale` when it should be taken again (older than a day, or than the | |
| 122 | + | * deploy the request names), which the caller does in the background. With | |
| 123 | + | * none kept, it is taken now. | |
| 124 | + | */ | |
| 125 | + | export async function externalOf(env: ShotEnv, request: ExternalShot, now = Date.now()): Promise<{ shot: Shot | null; stale: boolean }> { | |
| 126 | + | const kept = await storedExternal(env, request.host); | |
| 127 | + | if (kept) return { shot: kept, stale: !isFresh(kept, now, request.since) }; | |
| 128 | + | return { shot: await takeExternal(env, request), stale: false }; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /** | |
| 132 | + | * The daily refresh of productions deployed elsewhere: each address whose | |
| 133 | + | * screenshot is missing or more than a day old is taken again, the | |
| 134 | + | * stalest first, one at a time, at most `EXTERNAL_PER_RUN`. Addresses that | |
| 135 | + | * are not public are skipped. Returns how many were taken. | |
| 136 | + | */ | |
| 137 | + | export async function refreshExternal(env: ShotEnv, urls: readonly string[], now = Date.now()): Promise<number> { | |
| 138 | + | const day = dayOf(now); | |
| 139 | + | const byHost = new Map<string, ExternalShot>(); | |
| 140 | + | for (const url of urls) { | |
| 141 | + | const request = parseExternalShot({ url, commit: day }); | |
| 142 | + | if (request && !byHost.has(request.host)) byHost.set(request.host, request); | |
| 143 | + | } | |
| 144 | + | const targets = await Promise.all( | |
| 145 | + | [...byHost.values()].map(async (request) => { | |
| 146 | + | const head = await env.SCREENSHOTS.head(externalShotKey(request.host)).catch(() => null); | |
| 147 | + | return { request, capturedAt: head ? (head.customMetadata?.capturedAt ?? head.uploaded.toISOString()) : null }; | |
| 148 | + | }), | |
| 149 | + | ); | |
| 150 | + | let taken = 0; | |
| 151 | + | for (const { request } of dueForRefresh(targets, now)) { | |
| 152 | + | if (await takeExternal(env, request)) taken += 1; | |
| 153 | + | } | |
| 154 | + | return taken; | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | /** | |
| 75 | 158 | * Deletes screenshots and attempt notes older than `KEEP_DAYS`: apps that | |
| 76 | 159 | * moved or were removed leave theirs behind, and a live app's is taken | |
| 77 | 160 | * again the next time its page asks. Returns how many went. | |
| 78 | 161 | */ | |
| 79 | 162 | export async function sweep(env: ShotEnv, now = Date.now()): Promise<number> { | |
| 80 | 163 | let removed = 0; | |
| 81 | − | for (const prefix of ["production/", "attempts/"]) { | |
| 164 | + | for (const prefix of ["production/", "external/", "attempts/"]) { | |
| 82 | 165 | let cursor: string | undefined; | |
| 83 | 166 | do { | |
| 84 | 167 | const page = await env.SCREENSHOTS.list({ prefix, cursor, limit: 500 }); | |
| 13 | 13 | * a card shows does, so a new logo or an edited title gets a new address. | |
| 14 | 14 | * | |
| 15 | 15 | * Also the `Screenshots` entrypoint, reached only through service | |
| 16 | − | * bindings: a screenshot of each project's production, per deploy (see | |
| 16 | + | * bindings: a screenshot of each project's production, per deploy on | |
| 17 | + | * g1t.page, and of a production deployed elsewhere at its address (see | |
| 17 | 18 | * `capture.ts`). | |
| 18 | 19 | */ | |
| 19 | 20 | import { WorkerEntrypoint } from "cloudflare:workers"; | |
| ⋯ | |||
| 33 | 34 | import mono500 from "./fonts/ibm-plex-mono-500.ttf"; | |
| 34 | 35 | import { cardPng } from "./render.ts"; | |
| 35 | 36 | import { cacheKey, drawnKey } from "./cache.ts"; | |
| 36 | − | import { type Shot, screenshotOf, sweep, take } from "./capture.ts"; | |
| 37 | + | import { type Shot, externalOf, refreshExternal, screenshotOf, sweep, take, takeExternal } from "./capture.ts"; | |
| 37 | 38 | import { pausedCard } from "./paused.ts"; | |
| 38 | − | import { parseShot } from "./screenshot.ts"; | |
| 39 | + | import { parseExternalShot, parseShot } from "./screenshot.ts"; | |
| 39 | 40 | import { BRAND, type Card, cardPath, docsCard, resolve } from "./resolve.ts"; | |
| 40 | 41 | ||
| 41 | 42 | interface Env { | |
| ⋯ | |||
| 49 | 50 | BILLING?: ServiceBinding; | |
| 50 | 51 | /** Browser Rendering, for production screenshots. */ | |
| 51 | 52 | BROWSER: Fetcher; | |
| 52 | − | /** Production screenshots, by app hostname. */ | |
| 53 | + | /** Production screenshots, by app hostname; a production deployed elsewhere's under `external/`. */ | |
| 53 | 54 | SCREENSHOTS: R2Bucket; | |
| 54 | 55 | /** Cards drawn per address (RATE_LIMITS in packages/contracts). Absent: no limit. */ | |
| 55 | 56 | OG_RENDER_LIMIT?: RateLimitBinding; | |
| ⋯ | |||
| 78 | 79 | if (!request) return null; | |
| 79 | 80 | return screenshotOf(this.env, request); | |
| 80 | 81 | } | |
| 82 | + | ||
| 83 | + | /** | |
| 84 | + | * Takes the screenshot of a production deployed elsewhere, | |
| 85 | + | * `{ url, commit }` (`commit` a deploy's commit or a UTC day), in the | |
| 86 | + | * background. Called by projects when the address is set or changed and | |
| 87 | + | * when a production deployment is reported. False for an address that is | |
| 88 | + | * not public. | |
| 89 | + | */ | |
| 90 | + | async captureExternal(input: unknown): Promise<boolean> { | |
| 91 | + | const request = parseExternalShot(input); | |
| 92 | + | if (!request) return false; | |
| 93 | + | this.ctx.waitUntil(takeExternal(this.env, request)); | |
| 94 | + | return true; | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** | |
| 98 | + | * The kept screenshot of a production deployed elsewhere, `{ url, | |
| 99 | + | * commit, since? }`, taken now when there is none. One more than a day | |
| 100 | + | * old, or older than `since`, is returned as it is and taken again in | |
| 101 | + | * the background. | |
| 102 | + | */ | |
| 103 | + | async externalImage(input: unknown): Promise<Shot | null> { | |
| 104 | + | const request = parseExternalShot(input); | |
| 105 | + | if (!request) return null; | |
| 106 | + | const { shot, stale } = await externalOf(this.env, request); | |
| 107 | + | if (stale) this.ctx.waitUntil(takeExternal(this.env, request)); | |
| 108 | + | return shot; | |
| 109 | + | } | |
| 81 | 110 | } | |
| 82 | 111 | ||
| 83 | 112 | /* | |
| ⋯ | |||
| 107 | 136 | const NO_STORE = "no-store"; | |
| 108 | 137 | ||
| 109 | 138 | export default { | |
| 110 | − | /** Once a day: old screenshots out (capture.ts `sweep`). */ | |
| 139 | + | /** | |
| 140 | + | * Once a day: old screenshots out (capture.ts `sweep`), and productions | |
| 141 | + | * deployed elsewhere taken again when theirs is a day old | |
| 142 | + | * (`refreshExternal`). | |
| 143 | + | */ | |
| 111 | 144 | async scheduled(_event: ScheduledController, env: Env): Promise<void> { | |
| 112 | 145 | const removed = await sweep(env).catch((error) => (console.error("og: sweep failed", error), 0)); | |
| 113 | 146 | if (removed) console.log(`og: removed ${removed} old screenshots`); | |
| 147 | + | const urls = await projectsClient(env.PROJECTS) | |
| 148 | + | .externalProductions() | |
| 149 | + | .catch((error) => (console.error("og: could not list productions deployed elsewhere", error), [] as string[])); | |
| 150 | + | const taken = await refreshExternal(env, urls).catch((error) => (console.error("og: refresh failed", error), 0)); | |
| 151 | + | if (taken) console.log(`og: took ${taken} screenshots of productions deployed elsewhere`); | |
| 114 | 152 | }, | |
| 115 | 153 | ||
| 116 | 154 | async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { KEEP_DAYS, RETRY_AFTER_MS, attemptKey, expired, isCurrent, parseShot, shotKey, shouldAttempt } from "./screenshot.ts"; | |
| 4 | + | import { | |
| 5 | + | EXTERNAL_MAX_AGE_MS, | |
| 6 | + | KEEP_DAYS, | |
| 7 | + | RETRY_AFTER_MS, | |
| 8 | + | attemptKey, | |
| 9 | + | dayOf, | |
| 10 | + | dueForRefresh, | |
| 11 | + | expired, | |
| 12 | + | externalAttemptKey, | |
| 13 | + | externalShotKey, | |
| 14 | + | isCurrent, | |
| 15 | + | isFresh, | |
| 16 | + | isPublicHost, | |
| 17 | + | parseExternalShot, | |
| 18 | + | parseShot, | |
| 19 | + | shotKey, | |
| 20 | + | shouldAttempt, | |
| 21 | + | } from "./screenshot.ts"; | |
| 22 | + | ||
| 23 | + | test("an external production is visited only at a public address", () => { | |
| 24 | + | for (const host of ["g1t.sh", "www.g1t.sh", "example.com", "app.example.co.uk", "8.8.8.8", "acme-web.vercel.app"]) { | |
| 25 | + | assert.ok(isPublicHost(host), host); | |
| 26 | + | } | |
| 27 | + | for (const host of [ | |
| 28 | + | "localhost", | |
| 29 | + | "app.localhost", | |
| 30 | + | "printer.local", | |
| 31 | + | "db.internal", | |
| 32 | + | "intranet", | |
| 33 | + | "127.0.0.1", | |
| 34 | + | "10.1.2.3", | |
| 35 | + | "172.16.0.1", | |
| 36 | + | "172.31.255.255", | |
| 37 | + | "192.168.1.1", | |
| 38 | + | "169.254.169.254", | |
| 39 | + | "100.64.0.1", | |
| 40 | + | "0.0.0.0", | |
| 41 | + | "224.0.0.1", | |
| 42 | + | "999.1.1.1", | |
| 43 | + | "2130706433", | |
| 44 | + | "0x7f.0.0.1", | |
| 45 | + | "[::1]", | |
| 46 | + | "::1", | |
| 47 | + | "fe80::1", | |
| 48 | + | // g1t's own services, but g1t.sh itself. | |
| 49 | + | "api.g1t.sh", | |
| 50 | + | "og.g1t.sh", | |
| 51 | + | "sudo.g1t.sh", | |
| 52 | + | "g1tusercontent.com", | |
| 53 | + | "x.g1tusercontent.com", | |
| 54 | + | "web-acme.g1t.page", | |
| 55 | + | "g1t.page", | |
| 56 | + | ]) { | |
| 57 | + | assert.equal(isPublicHost(host), false, host); | |
| 58 | + | } | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | test("an external request names a public http(s) address and a commit or a day", () => { | |
| 62 | + | const commit = "abc1234def5678abc1234def5678abc1234def56"; | |
| 63 | + | assert.deepEqual(parseExternalShot({ url: "https://G1T.sh/#top", commit }), { host: "g1t.sh", url: "https://g1t.sh/", commit }); | |
| 64 | + | assert.deepEqual(parseExternalShot({ url: "http://example.com/app?x=1", commit: "2026-10-11", since: "2026-10-11T00:00:00Z" }), { | |
| 65 | + | host: "example.com", | |
| 66 | + | url: "http://example.com/app?x=1", | |
| 67 | + | commit: "2026-10-11", | |
| 68 | + | since: "2026-10-11T00:00:00Z", | |
| 69 | + | }); | |
| 70 | + | for (const url of [ | |
| 71 | + | "ftp://example.com/", | |
| 72 | + | "file:///etc/passwd", | |
| 73 | + | "javascript:alert(1)", | |
| 74 | + | "https://user:pass@example.com/", | |
| 75 | + | "https://example.com:8443/", | |
| 76 | + | "http://127.0.0.1/", | |
| 77 | + | "http://169.254.169.254/latest/meta-data", | |
| 78 | + | "https://api.g1t.sh/", | |
| 79 | + | "https://web-acme.g1t.page/", | |
| 80 | + | "not a url", | |
| 81 | + | ]) { | |
| 82 | + | assert.equal(parseExternalShot({ url, commit }), null, url); | |
| 83 | + | } | |
| 84 | + | assert.equal(parseExternalShot({ url: "https://example.com/", commit: "main" }), null); | |
| 85 | + | assert.equal(parseExternalShot({ url: "https://example.com/", commit: "2026-13-45" }), null); | |
| 86 | + | assert.equal(parseExternalShot({ url: "https://example.com/" }), null); | |
| 87 | + | assert.equal(parseExternalShot(null), null); | |
| 88 | + | }); | |
| 89 | + | ||
| 90 | + | test("external screenshots are kept apart, by host", () => { | |
| 91 | + | assert.equal(externalShotKey("g1t.sh"), "external/g1t.sh.jpg"); | |
| 92 | + | assert.equal(externalAttemptKey("g1t.sh"), "attempts/external/g1t.sh"); | |
| 93 | + | assert.equal(dayOf(Date.parse("2026-10-11T23:59:00Z")), "2026-10-11"); | |
| 94 | + | }); | |
| 95 | + | ||
| 96 | + | test("an external screenshot stands for a day, and not from before the deploy", () => { | |
| 97 | + | const now = Date.parse("2026-10-11T12:00:00Z"); | |
| 98 | + | const kept = { capturedAt: "2026-10-11T06:00:00Z" }; | |
| 99 | + | assert.ok(isFresh(kept, now)); | |
| 100 | + | assert.ok(isFresh(kept, now, "2026-10-11T05:00:00Z")); | |
| 101 | + | assert.ok(!isFresh(kept, now, "2026-10-11T07:00:00Z")); | |
| 102 | + | assert.ok(!isFresh({ capturedAt: new Date(now - EXTERNAL_MAX_AGE_MS).toISOString() }, now)); | |
| 103 | + | assert.ok(!isFresh(null, now)); | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | test("the daily refresh takes the missing and the stalest first, up to its limit", () => { | |
| 107 | + | const now = Date.parse("2026-10-11T12:00:00Z"); | |
| 108 | + | const targets = [ | |
| 109 | + | { name: "fresh", capturedAt: "2026-10-11T06:00:00Z" }, | |
| 110 | + | { name: "two days", capturedAt: "2026-10-09T06:00:00Z" }, | |
| 111 | + | { name: "missing", capturedAt: null }, | |
| 112 | + | { name: "a week", capturedAt: "2026-10-04T06:00:00Z" }, | |
| 113 | + | ]; | |
| 114 | + | assert.deepEqual( | |
| 115 | + | dueForRefresh(targets, now).map((t) => t.name), | |
| 116 | + | ["missing", "a week", "two days"], | |
| 117 | + | ); | |
| 118 | + | assert.deepEqual( | |
| 119 | + | dueForRefresh(targets, now, 1).map((t) => t.name), | |
| 120 | + | ["missing"], | |
| 121 | + | ); | |
| 122 | + | }); | |
| 5 | 123 | ||
| 6 | 124 | const commit = "abc1234def5678abc1234def5678abc1234def56"; | |
| 7 | 125 |
| 1 | 1 | /** | |
| 2 | 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. Only apps on | |
| 4 | − | * g1t.page are ever visited, and only by hostname: a custom domain serves | |
| 5 | − | * the same app. | |
| 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). | |
| 6 | 7 | */ | |
| 7 | 8 | ||
| 8 | 9 | export const DEPLOYMENTS_DOMAIN = "g1t.page"; | |
| ⋯ | |||
| 71 | 72 | const at = Date.parse(last.at); | |
| 72 | 73 | return !Number.isFinite(at) || now - at >= RETRY_AFTER_MS; | |
| 73 | 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. */ | |
| 88 | + | export 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. */ | |
| 90 | + | export const EXTERNAL_PER_RUN = 40; | |
| 91 | + | ||
| 92 | + | /** An external production: the address to visit, keyed by its host. */ | |
| 93 | + | export type ExternalShot = ShotRequest & { url: string }; | |
| 94 | + | ||
| 95 | + | /** Where an external production's screenshot is kept. */ | |
| 96 | + | export 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. */ | |
| 101 | + | export 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. */ | |
| 106 | + | export 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 | + | */ | |
| 115 | + | const OWN_DOMAINS = ["g1t.sh", "g1tusercontent.com", DEPLOYMENTS_DOMAIN]; | |
| 116 | + | /** g1t's own site is a production like any other. */ | |
| 117 | + | const OWN_ALLOWED = new Set(["g1t.sh", "www.g1t.sh"]); | |
| 118 | + | /** Names that only resolve on a private network, or never on the internet. */ | |
| 119 | + | const 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. */ | |
| 122 | + | function 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 | + | */ | |
| 149 | + | export 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 | + | */ | |
| 169 | + | export 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 | + | */ | |
| 195 | + | export 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 | + | */ | |
| 206 | + | export 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 | + | } | |
| 21 | 21 | // Screenshots of each project's production, one per deploy, taken by | |
| 22 | 22 | // Browser Rendering and kept in R2. Reached only through the | |
| 23 | 23 | // `Screenshots` entrypoint: deployments asks for one when production | |
| 24 | − | // goes live, and the site reads it for a project's overview. | |
| 24 | + | // goes live, projects when a production deployed elsewhere gets an | |
| 25 | + | // address or reports a deploy, and the site reads it for a project's | |
| 26 | + | // overview. | |
| 25 | 27 | "browser": { "binding": "BROWSER" }, | |
| 26 | 28 | // Made by `npx wrangler r2 bucket create g1t-screenshots`. | |
| 27 | 29 | "r2_buckets": [{ "binding": "SCREENSHOTS", "bucket_name": "g1t-screenshots" }], | |
| 28 | − | // Once a day, screenshots older than 30 days are deleted (src/capture.ts). | |
| 30 | + | // Once a day, screenshots older than 30 days are deleted, and each | |
| 31 | + | // production deployed elsewhere whose screenshot is a day old is taken | |
| 32 | + | // again (src/capture.ts). | |
| 29 | 33 | "triggers": { "crons": ["37 5 * * *"] }, | |
| 30 | 34 | "services": [ | |
| 31 | 35 | { "binding": "IDENTITY", "service": "g1t-identity" }, |
| 1 | + | -- An uploaded image of a project's production (src/production-image.ts): | |
| 2 | + | -- the SHA-256 of its bytes, kept in the avatars namespace under | |
| 3 | + | -- `production/<sha256>` and served from the usercontent origin; the type | |
| 4 | + | -- read from its bytes; and whether it is shown instead of a screenshot. | |
| 5 | + | ALTER TABLE projects ADD COLUMN production_image TEXT; | |
| 6 | + | ALTER TABLE projects ADD COLUMN production_image_type TEXT; | |
| 7 | + | ALTER TABLE projects ADD COLUMN production_image_preferred INTEGER NOT NULL DEFAULT 0; |
| 33 | 33 | import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins"; | |
| 34 | 34 | import { renameStatements } from "./rename"; | |
| 35 | 35 | import { moveStatements, slugOf, strandedQuery } from "./transfer"; | |
| 36 | + | import { checkProductionImage, productionImageKey, productionImageOf, sha256, today, wantsScreenshot } from "./production-image"; | |
| 36 | 37 | ||
| 37 | 38 | import { | |
| 38 | 39 | can, | |
| ⋯ | |||
| 56 | 57 | type NewProject, | |
| 57 | 58 | type Project, | |
| 58 | 59 | type ProjectChanges, | |
| 60 | + | type ProductionImageChange, | |
| 59 | 61 | type ProjectKind, | |
| 60 | 62 | type ProjectEcosystem, | |
| 61 | 63 | type ProjectShortcuts, | |
| ⋯ | |||
| 72 | 74 | IDENTITY: ServiceBinding; | |
| 73 | 75 | PACKAGES: ServiceBinding; | |
| 74 | 76 | DEPLOYMENTS: ServiceBinding; | |
| 77 | + | /** The avatars namespace: uploaded production images, under `production/<sha256>`, which the usercontent origin serves. */ | |
| 78 | + | AVATARS: KVNamespace; | |
| 79 | + | /** | |
| 80 | + | * The og service's `Screenshots` entrypoint: a screenshot of a production | |
| 81 | + | * deployed elsewhere, taken when its address is set or changed and when | |
| 82 | + | * a production deployment is reported. Absent: none is asked for. | |
| 83 | + | */ | |
| 84 | + | SCREENSHOTS?: { captureExternal(input: { url: string; commit: string }): Promise<boolean> }; | |
| 75 | 85 | }; | |
| 76 | 86 | ||
| 77 | 87 | type Row = { | |
| ⋯ | |||
| 102 | 112 | kind_setting?: string | null; | |
| 103 | 113 | runs_setting?: string | null; | |
| 104 | 114 | production_url?: string | null; | |
| 115 | + | /** An uploaded image of production, by its SHA-256 (migrations/0012_production_image.sql). */ | |
| 116 | + | production_image?: string | null; | |
| 117 | + | production_image_type?: string | null; | |
| 118 | + | production_image_preferred?: number | null; | |
| 105 | 119 | /** Its own homepage; null follows `repo_website`. */ | |
| 106 | 120 | homepage?: string | null; | |
| 107 | 121 | repo_website?: string | null; | |
| ⋯ | |||
| 170 | 184 | kindReason: reason, | |
| 171 | 185 | runs, | |
| 172 | 186 | productionUrl: row.production_url ?? null, | |
| 187 | + | productionImage: productionImageOf(row), | |
| 173 | 188 | detected: detectedKind(facts), | |
| 174 | 189 | ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null, | |
| 175 | 190 | links: projectLinks(row), | |
| ⋯ | |||
| 487 | 502 | let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!; | |
| 488 | 503 | // Another root has other files: read them again. | |
| 489 | 504 | if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved; | |
| 490 | − | return ok(toProject(saved)); | |
| 505 | + | const project = toProject(saved); | |
| 506 | + | // Production elsewhere given an address, or a new one: its screenshot, now. | |
| 507 | + | const was = toProject(row); | |
| 508 | + | if (wantsScreenshot(project) && (project.productionUrl !== was.productionUrl || was.runs !== "elsewhere")) { | |
| 509 | + | this.screenshot(project.productionUrl!, today()); | |
| 510 | + | } | |
| 511 | + | return ok(project); | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | /** | |
| 515 | + | * Asks og for a screenshot of a production deployed elsewhere (og takes | |
| 516 | + | * it in the background). Never fails: the overview and the daily | |
| 517 | + | * refresh take it later anyway. | |
| 518 | + | */ | |
| 519 | + | private async capture(url: string, commit: string): Promise<void> { | |
| 520 | + | await this.env.SCREENSHOTS?.captureExternal({ url, commit }).catch((error) => console.warn("could not ask for a screenshot", url, error)); | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | /** The same, after the answer is sent. */ | |
| 524 | + | private screenshot(url: string, commit: string): void { | |
| 525 | + | this.defer(this.capture(url, commit)); | |
| 491 | 526 | } | |
| 492 | 527 | ||
| 528 | + | async setProductionImage(a: { | |
| 529 | + | actor: User; | |
| 530 | + | workspace: string; | |
| 531 | + | slug: string; | |
| 532 | + | change: ProductionImageChange; | |
| 533 | + | }): Promise<Result<Project>> { | |
| 534 | + | const change = a.change ?? {}; | |
| 535 | + | const row = await this.db | |
| 536 | + | .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL") | |
| 537 | + | .bind(a.workspace.toLowerCase(), a.slug.toLowerCase()) | |
| 538 | + | .first<Row>(); | |
| 539 | + | if (!row) return fail("not_found", "There is no such project."); | |
| 540 | + | // Whoever may change its production address. | |
| 541 | + | const allowed = this.changeable(row, a.actor, NEEDS.update); | |
| 542 | + | if (!allowed.ok) return allowed; | |
| 543 | + | let file = row.production_image ?? null; | |
| 544 | + | let type = row.production_image_type ?? null; | |
| 545 | + | let preferred = !!row.production_image_preferred; | |
| 546 | + | if (change.image === null) { | |
| 547 | + | file = null; | |
| 548 | + | type = null; | |
| 549 | + | preferred = false; | |
| 550 | + | } else if (change.image !== undefined) { | |
| 551 | + | const checked = checkProductionImage(change.image); | |
| 552 | + | if (!checked.ok) return fail("invalid", checked.message); | |
| 553 | + | file = await sha256(checked.bytes); | |
| 554 | + | type = checked.contentType; | |
| 555 | + | // Kept by its hash, so the same image stored twice is one file; its | |
| 556 | + | // type is the one read from its bytes (usercontent serves only that). | |
| 557 | + | await this.env.AVATARS.put(productionImageKey(file), checked.bytes, { metadata: { contentType: type } }); | |
| 558 | + | } | |
| 559 | + | if (change.preferred !== undefined) { | |
| 560 | + | if (change.preferred && !file) return fail("invalid", "Upload an image first."); | |
| 561 | + | preferred = !!change.preferred; | |
| 562 | + | } | |
| 563 | + | await this.db | |
| 564 | + | .prepare("UPDATE projects SET production_image = ?, production_image_type = ?, production_image_preferred = ?, updated_at = ? WHERE id = ?") | |
| 565 | + | .bind(file, type, preferred ? 1 : 0, now(), row.id) | |
| 566 | + | .run(); | |
| 567 | + | // The image it replaced goes, unless another project shows it too. | |
| 568 | + | const old = row.production_image; | |
| 569 | + | if (old && old !== file) { | |
| 570 | + | const used = await this.db.prepare("SELECT 1 FROM projects WHERE production_image = ? LIMIT 1").bind(old).first(); | |
| 571 | + | if (!used) this.defer(this.env.AVATARS.delete(productionImageKey(old)).catch((error) => console.warn("could not delete an image", error))); | |
| 572 | + | } | |
| 573 | + | const saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!; | |
| 574 | + | const project = toProject(saved); | |
| 575 | + | // No longer shown instead of a screenshot: one is taken, if none is fresh. | |
| 576 | + | if (row.production_image_preferred && !preferred && wantsScreenshot(project)) this.screenshot(project.productionUrl!, today()); | |
| 577 | + | return ok(project); | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | /** Every production address deployed elsewhere that shows a screenshot, once each. */ | |
| 581 | + | async externalProductions(): Promise<string[]> { | |
| 582 | + | const rows = await this.db | |
| 583 | + | .prepare( | |
| 584 | + | `SELECT * FROM projects WHERE production_url IS NOT NULL AND repo_deleted_at IS NULL | |
| 585 | + | AND (runs_setting IS NULL OR runs_setting = 'elsewhere') AND production_image_preferred = 0`, | |
| 586 | + | ) | |
| 587 | + | .all<Row>(); | |
| 588 | + | const urls = rows.results.map(toProject).filter(wantsScreenshot).map((project) => project.productionUrl!); | |
| 589 | + | return [...new Set(urls)]; | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | /** | |
| 593 | + | * A production deployment reported from outside g1t.page succeeded: a | |
| 594 | + | * screenshot of each of its repository's projects deployed elsewhere, | |
| 595 | + | * at the address the deployment gives, else the project's own. | |
| 596 | + | */ | |
| 597 | + | private async productionReported(data: Extract<G1tEvent, { type: "deployment_status.created" }>["data"]): Promise<void> { | |
| 598 | + | const { deployment, deploymentStatus } = data; | |
| 599 | + | if (deploymentStatus.state !== "success" || !deployment.production_environment || deployment.source === "g1t_page") return; | |
| 600 | + | const rows = await this.db | |
| 601 | + | .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL ORDER BY is_primary DESC") | |
| 602 | + | .bind(data.repoId) | |
| 603 | + | .all<Row>(); | |
| 604 | + | // The repository's own project first: the overview shows the reported | |
| 605 | + | // production on it, as running elsewhere when nobody has said otherwise. | |
| 606 | + | const project = rows.results | |
| 607 | + | .map(toProject) | |
| 608 | + | .find((one) => one.runs === "elsewhere" || (one.runs === null && one.kind === "app")); | |
| 609 | + | if (!project || project.productionImage?.preferred) return; | |
| 610 | + | const url = deploymentStatus.environment_url ?? deployment.environment_url ?? project.productionUrl; | |
| 611 | + | if (url) await this.capture(url, deployment.sha); | |
| 612 | + | } | |
| 613 | + | ||
| 493 | 614 | async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> { | |
| 494 | 615 | const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))]; | |
| 495 | 616 | if (keys.length === 0) return {}; | |
| ⋯ | |||
| 776 | 897 | const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined); | |
| 777 | 898 | if (repoId) await this.active(repoId, event.time, event.type === "git.push"); | |
| 778 | 899 | } | |
| 900 | + | if (event.type === "deployment_status.created") { | |
| 901 | + | await this.productionReported(event.data); | |
| 902 | + | return; | |
| 903 | + | } | |
| 779 | 904 | if (event.type === "workspace.renamed") { | |
| 780 | 905 | const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data); | |
| 781 | 906 | const statements = renameStatements(staleSlugs(event.data, current), current); | |
| ⋯ | |||
| 942 | 1067 | return Response.json(await service.create(args)); | |
| 943 | 1068 | case "update": | |
| 944 | 1069 | return Response.json(await service.update(args)); | |
| 1070 | + | case "set_production_image": | |
| 1071 | + | return Response.json(await service.setProductionImage(args)); | |
| 1072 | + | case "external_productions": | |
| 1073 | + | return Response.json(await service.externalProductions()); | |
| 945 | 1074 | case "deployments_changed": | |
| 946 | 1075 | await service.deploymentsChanged(args); | |
| 947 | 1076 | return Response.json(null); | |
| 1 | + | import { test } from "node:test"; | |
| 2 | + | import assert from "node:assert/strict"; | |
| 3 | + | ||
| 4 | + | import { | |
| 5 | + | MAX_PRODUCTION_IMAGE_BYTES, | |
| 6 | + | checkProductionImage, | |
| 7 | + | productionImageKey, | |
| 8 | + | productionImageOf, | |
| 9 | + | sha256, | |
| 10 | + | sniffProductionImage, | |
| 11 | + | today, | |
| 12 | + | wantsScreenshot, | |
| 13 | + | } from "./production-image.ts"; | |
| 14 | + | ||
| 15 | + | const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]; | |
| 16 | + | const JPEG = [0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]; | |
| 17 | + | const WEBP = [...Buffer.from("RIFF"), 0, 0, 0, 0, ...Buffer.from("WEBP")]; | |
| 18 | + | const GIF = [...Buffer.from("GIF89a"), 0, 0, 0, 0, 0, 0]; | |
| 19 | + | const base64 = (bytes: number[] | Uint8Array) => Buffer.from(bytes).toString("base64"); | |
| 20 | + | ||
| 21 | + | test("a production image is a PNG, JPEG or WebP, by its bytes", () => { | |
| 22 | + | assert.equal(sniffProductionImage(Uint8Array.from(PNG)), "image/png"); | |
| 23 | + | assert.equal(sniffProductionImage(Uint8Array.from(JPEG)), "image/jpeg"); | |
| 24 | + | assert.equal(sniffProductionImage(Uint8Array.from(WEBP)), "image/webp"); | |
| 25 | + | assert.equal(sniffProductionImage(Uint8Array.from(GIF)), null); | |
| 26 | + | assert.equal(sniffProductionImage(new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'/>")), null); | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("an upload is checked: present, at most 5 MB, and an image", () => { | |
| 30 | + | const png = checkProductionImage(base64(PNG)); | |
| 31 | + | assert.ok(png.ok); | |
| 32 | + | assert.equal(png.ok && png.contentType, "image/png"); | |
| 33 | + | assert.deepEqual(checkProductionImage(""), { ok: false, message: "Choose a PNG, JPEG or WebP image." }); | |
| 34 | + | assert.deepEqual(checkProductionImage(null), { ok: false, message: "Choose a PNG, JPEG or WebP image." }); | |
| 35 | + | assert.deepEqual(checkProductionImage("%%%"), { ok: false, message: "Choose a PNG, JPEG or WebP image." }); | |
| 36 | + | assert.deepEqual(checkProductionImage(base64(GIF)), { ok: false, message: "Use a PNG, JPEG or WebP image." }); | |
| 37 | + | const big = new Uint8Array(MAX_PRODUCTION_IMAGE_BYTES + 1); | |
| 38 | + | big.set(PNG); | |
| 39 | + | assert.deepEqual(checkProductionImage(base64(big)), { ok: false, message: "Use an image of at most 5 MB." }); | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | test("an image is kept by its hash, apart from avatars", async () => { | |
| 43 | + | const file = await sha256(new TextEncoder().encode("abc")); | |
| 44 | + | assert.equal(file, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); | |
| 45 | + | assert.equal(productionImageKey(file), `production/${file}`); | |
| 46 | + | }); | |
| 47 | + | ||
| 48 | + | test("the stored image, and whether it is preferred", () => { | |
| 49 | + | assert.equal(productionImageOf({}), null); | |
| 50 | + | assert.deepEqual(productionImageOf({ production_image: "ab", production_image_type: "image/webp", production_image_preferred: 1 }), { | |
| 51 | + | file: "ab", | |
| 52 | + | contentType: "image/webp", | |
| 53 | + | preferred: true, | |
| 54 | + | }); | |
| 55 | + | assert.equal(productionImageOf({ production_image: "ab", production_image_preferred: 0 })?.preferred, false); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | test("only production elsewhere, at an address and not shown as an upload, is visited", () => { | |
| 59 | + | const elsewhere = { runs: "elsewhere", productionUrl: "https://g1t.sh", productionImage: null }; | |
| 60 | + | assert.ok(wantsScreenshot(elsewhere)); | |
| 61 | + | assert.ok(wantsScreenshot({ ...elsewhere, productionImage: { preferred: false } })); | |
| 62 | + | assert.ok(!wantsScreenshot({ ...elsewhere, productionImage: { preferred: true } })); | |
| 63 | + | assert.ok(!wantsScreenshot({ ...elsewhere, productionUrl: null })); | |
| 64 | + | assert.ok(!wantsScreenshot({ ...elsewhere, runs: "g1t" })); | |
| 65 | + | assert.ok(!wantsScreenshot({ ...elsewhere, runs: null })); | |
| 66 | + | }); | |
| 67 | + | ||
| 68 | + | test("the day a screenshot is versioned by", () => { | |
| 69 | + | assert.equal(today(Date.parse("2026-10-11T23:30:00Z")), "2026-10-11"); | |
| 70 | + | }); |
Loading the diff…
Loading the diff…