A project's production card always has a picture now, wherever production runs. An app deployed elsewhere gets a screenshot of its production address, taken by the og service with Browser Rendering and kept apart from g1t.page apps' under external/<host>.jpg: when the address is set or changed (projects asks og through a new Screenshots binding), when a production deployment reported from outside g1t.page succeeds (projects now hears deployment_status.created), and otherwise at most once a day from og's daily cron, which refreshes up to 40 of the stalest; opening the overview after a day also takes a new one in the background. Only public http(s) addresses on their usual ports are visited: private, loopback, link-local and reserved addresses, private-network names and g1t's own service hosts (but g1t.sh itself) are refused, and capture.ts's cost notes count the new shots.
Any project can also have an uploaded production image, under Settings > General > Production image: a PNG, JPEG or WebP of at most 5 MB, checked by its bytes in the projects service, kept by its SHA-256 in the avatars namespace under production/<hash> and served from the usercontent origin at /production-images/<hash>, never from g1t.sh. It shows when there is no screenshot, or instead of one with "Use the uploaded image instead of a screenshot" (and then no screenshot of an external production is taken); Remove takes it away. Whoever may change the project's settings and production address (Maintain and up) may change it. With neither, the card shows the quiet frame with the address, offering "Add an image of your app" to those who may add one. The docs describe the picture, how often external screenshots are taken, the upload and who can change it.
| 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 | + | }); |