Show production screenshots to public project readers
5 files+64−140/5 viewed
| 18 | 18 | pageRange, | |
| 19 | 19 | parseFilter, | |
| 20 | 20 | productionEnvironment, | |
| 21 | + | nativeProduction, | |
| 21 | 22 | shortSha, | |
| 22 | 23 | withFilter, | |
| 23 | 24 | } from "./deployments.ts"; | |
| ⋯ | |||
| 212 | 213 | assert.equal(isNewestStatus(tied, 1), true); | |
| 213 | 214 | assert.equal(isNewestStatus([], 0), false); | |
| 214 | 215 | }); | |
| 216 | + | ||
| 217 | + | test("native screenshots use the serving app, not a newer attempt or custom domain", () => { | |
| 218 | + | const current = { source: "g1t_page", state: "success", environment_url: "https://app-acme.g1t.page", sha: "abcdef1", updated_at: "2026-10-15T01:00:00Z" }; | |
| 219 | + | const production = { ...env("production", { production_environment: true, latest: deployed }), current, url: "https://custom.example" }; | |
| 220 | + | assert.deepEqual(nativeProduction([production as never]), { url: current.environment_url, commit: current.sha, deployedAt: current.updated_at }); | |
| 221 | + | assert.equal(nativeProduction([{ ...production, current: null } as never]), null); | |
| 222 | + | assert.equal(nativeProduction([{ ...production, current: { ...current, state: "inactive" } } as never]), null); | |
| 223 | + | assert.equal(nativeProduction([{ ...production, current: { ...current, source: "actions" } } as never]), null); | |
| 224 | + | }); | |
| 140 | 140 | return orderEnvironments(environments).find((env) => env.production_environment && env.latest != null) ?? null; | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | + | /** The serving native app, from deployment metadata available to every repository reader. */ | |
| 144 | + | export function nativeProduction(environments: readonly DeploymentEnvironment[]): { url: string; commit: string; deployedAt: string } | null { | |
| 145 | + | const current = productionEnvironment(environments)?.current; | |
| 146 | + | if (current?.source !== "g1t_page" || current.state !== "success" || !current.environment_url) return null; | |
| 147 | + | return { url: current.environment_url, commit: current.sha, deployedAt: current.updated_at }; | |
| 148 | + | } | |
| 149 | + | ||
| 143 | 150 | /** | |
| 144 | 151 | * Production deployed somewhere other than g1t.page (by g1t Actions, or | |
| 145 | 152 | * reported through the API), as an app that deploys itself shows it: the |
| 56 | 56 | import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server"; | |
| 57 | 57 | import { shotVersion } from "./production-screenshot"; | |
| 58 | 58 | import { DeploymentStateIcon, DeploymentsPanel } from "../../components/deployments-panel"; | |
| 59 | − | import { STATE_WORD, environmentUrl, productionEnvironment, reportedProduction } from "../../lib/deployments"; | |
| 59 | + | import { STATE_WORD, nativeProduction, productionEnvironment, reportedProduction } from "../../lib/deployments"; | |
| 60 | 60 | import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image"; | |
| 61 | 61 | import { useAddresses } from "../../lib/addresses"; | |
| 62 | 62 | import { commitChecksFor } from "../../lib/commit-checks.server"; | |
| ⋯ | |||
| 267 | 267 | // anyone else from the production environment, which they may read. | |
| 268 | 268 | const liveProduction = live.find((app) => app.kind === "production") ?? null; | |
| 269 | 269 | const current = productionEnv?.current ?? null; | |
| 270 | − | const production = liveProduction | |
| 270 | + | const production = nativeProduction(environmentList?.environments ?? []) ?? (liveProduction | |
| 271 | 271 | ? { url: liveProduction.url, commit: liveProduction.commit, deployedAt: liveProduction.deployedAt } | |
| 272 | − | : productionEnv && current?.source === "g1t_page" && environmentUrl(productionEnv) | |
| 273 | − | ? { url: environmentUrl(productionEnv)!, commit: current.sha, deployedAt: current.updated_at } | |
| 274 | − | : null; | |
| 272 | + | : null); | |
| 275 | 273 | const telemetryData = observed?.overview && (observed.overview.totals.requests > 0 || (observed.errors?.length ?? 0) > 0) ? observed : null; | |
| 276 | 274 | ||
| 277 | 275 | return { | |
| ⋯ | |||
| 285 | 283 | previews: live.filter((app) => app.kind === "preview"), | |
| 286 | 284 | production, | |
| 287 | 285 | serving: current ? { id: current.id, sha: current.sha, ref: current.ref, at: current.updated_at, creator: current.creator, runUrl: current.run_url } : null, | |
| 288 | − | // The screenshot of production on g1t.page, for members (like its builds). | |
| 289 | − | productionShot: member && liveProduction ? shotVersion(liveProduction) : null, | |
| 286 | + | // Every reader can see production; administrative builds and settings remain member-only. | |
| 287 | + | productionShot: production ? shotVersion(production) : null, | |
| 290 | 288 | latestProductionBuild: latestProduction ? { status: latestProduction.status } : null, | |
| 291 | 289 | productionLatest: productionLatest | |
| 292 | 290 | ? { id: productionLatest.id, sha: productionLatest.sha, state: productionLatest.state, at: productionLatest.updated_at, environment: productionEnv!.name } | |
| ⋯ | |||
| 791 | 789 | </p> | |
| 792 | 790 | )} | |
| 793 | 791 | {productionPlan && <div className="flex flex-wrap items-center justify-between gap-3 border-t border-line px-5 py-3 sm:px-6"> | |
| 794 | − | <span className="text-xs text-muted">{plan === "elsewhere" ? "Production is deployed by its own pipeline." : !loaderData.settings?.enabled ? "Deployments are not enabled for this project." : <>Push to <span className="font-mono text-fg-soft">{defaultBranch ?? "the default branch"}</span> to update production.</>}</span> | |
| 792 | + | <span className="text-xs text-muted">{plan === "elsewhere" ? "Production is deployed by its own pipeline." : loaderData.settings?.enabled === false ? "Deployments are not enabled for this project." : <>Push to <span className="font-mono text-fg-soft">{defaultBranch ?? "the default branch"}</span> to update production.</>}</span> | |
| 795 | 793 | <div className="flex flex-wrap items-center gap-2"> | |
| 796 | 794 | {member && loaderData.can.manage_integrations && <ButtonLink to={`${base}/settings/deployments`} variant="ghost" size="sm"><Settings2 size={14} />Deployment settings</ButtonLink>} | |
| 797 | 795 | <ButtonLink to={`${base}/deployments`} variant="outline" size="sm"><Rocket size={14} />Deployments{loaderData.environments && <span className="text-muted">{loaderData.environments.total_count}</span>}</ButtonLink> | |
| 1 | 1 | /** | |
| 2 | − | * A screenshot of a project's production, for its overview. Members only, | |
| 3 | − | * like the deployments it shows. The overview asks with `?v=` naming the | |
| 2 | + | * A screenshot of a project's production, for anyone allowed to read it. | |
| 3 | + | * The overview asks with `?v=` naming the | |
| 4 | 4 | * deploy (its commit and when it went live), so the browser keeps a | |
| 5 | 5 | * screenshot of that deploy for good, and a resumed or moved app, deployed | |
| 6 | 6 | * again at the same commit, is asked for anew. One taken before the | |
| ⋯ | |||
| 11 | 11 | import type { Route } from "./+types/production-screenshot"; | |
| 12 | 12 | import { deployments } from "../../lib/services.server"; | |
| 13 | 13 | import { getViewer } from "../../lib/session.server"; | |
| 14 | + | import { projectFor } from "../../lib/access.server"; | |
| 15 | + | import { nativeProduction } from "../../lib/deployments"; | |
| 14 | 16 | ||
| 15 | 17 | const LONG = "private, max-age=31536000, immutable"; | |
| 16 | 18 | ||
| ⋯ | |||
| 23 | 25 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 24 | 26 | const viewer = getViewer(context); | |
| 25 | 27 | const none = (status: number) => new Response(null, { status, headers: { "cache-control": BRIEF } }); | |
| 26 | − | if (!viewer || !env.SCREENSHOTS) return none(404); | |
| 27 | − | const list = await deployments.list({ workspace: params.owner, slug: params.repo }, viewer).catch(() => null); | |
| 28 | − | const production = list?.ok ? list.value.live.find((app) => app.kind === "production") : undefined; | |
| 28 | + | if (!env.SCREENSHOTS) return none(404); | |
| 29 | + | const project = await projectFor(context, params).catch(() => null); | |
| 30 | + | if (!project?.ok) return none(404); | |
| 31 | + | const repo = project.value.source.kind === "hosted" ? project.value.source.repo : { namespace: params.owner, name: params.repo }; | |
| 32 | + | const environments = await deployments.environments(repo, viewer).catch(() => null); | |
| 33 | + | const production = nativeProduction(environments?.ok ? environments.value.environments : []); | |
| 29 | 34 | if (!production) return none(404); | |
| 30 | 35 | const shot = await env.SCREENSHOTS.image({ | |
| 31 | 36 | host: new URL(production.url).host, | |
| 14 | 14 | // Seed R2 and the page cache through a test-only entry, keeping all R2 I/O | |
| 15 | 15 | // inside workerd. The application entry itself is the unmodified build. | |
| 16 | 16 | const seed = `import app from './index.js'; | |
| 17 | + | import { WorkerEntrypoint } from 'cloudflare:workers'; | |
| 18 | + | export class ScreenshotStub extends WorkerEntrypoint { | |
| 19 | + | async image(input) { | |
| 20 | + | if (input.host !== 'public-acme.g1t.page' || input.commit !== 'abcdef1234567') throw new Error('Unexpected screenshot target'); | |
| 21 | + | return {body:new TextEncoder().encode('production-preview').buffer,contentType:'image/jpeg',commit:input.commit,capturedAt:'2026-10-15T02:00:00Z'}; | |
| 22 | + | } | |
| 23 | + | } | |
| 17 | 24 | export default { async fetch(request, env, ctx) { | |
| 18 | 25 | if (new URL(request.url).pathname === '/__seed') { | |
| 19 | 26 | await env.DOWNLOADS.put('web-assets/assets/app-old12345.css', ${JSON.stringify(css)}, {httpMetadata:{contentType:'text/css'}}); | |
| ⋯ | |||
| 34 | 41 | r2Buckets: ["DOWNLOADS"], | |
| 35 | 42 | serviceBindings: { | |
| 36 | 43 | IDENTITY: async () => Response.json(null), | |
| 37 | − | PROJECTS: async () => Response.json({ ok: true, value: [] }), | |
| 44 | + | PROJECTS: async (request) => { | |
| 45 | + | const method = new URL(request.url).pathname.split("/").pop(); | |
| 46 | + | const args = await request.json(); | |
| 47 | + | if (method !== "get") return Response.json({ ok: true, value: [] }); | |
| 48 | + | if (args.slug !== "public") return Response.json({ ok: false, error: { code: "not_found", message: "Not found" } }); | |
| 49 | + | return Response.json({ ok: true, value: { source: { kind: "hosted", repo: { namespace: "acme", name: "public" } } } }); | |
| 50 | + | }, | |
| 51 | + | DEPLOYMENTS: async (request) => { | |
| 52 | + | const method = new URL(request.url).pathname.split("/").pop(); | |
| 53 | + | const args = await request.json(); | |
| 54 | + | assert.equal(method, "list_environments", "Public previews must not read member-only deployment lists"); | |
| 55 | + | assert.equal(args.viewer, null); | |
| 56 | + | assert.equal(args.repo.name, "public"); | |
| 57 | + | const current = { id: "dpl_live", source: "g1t_page", state: "success", sha: "abcdef1234567", environment_url: "https://public-acme.g1t.page", updated_at: "2026-10-15T01:00:00Z" }; | |
| 58 | + | return Response.json({ ok: true, value: { environments: [{ name: "production", production_environment: true, updated_at: current.updated_at, current, latest: { ...current, id: "dpl_next", sha: "bbbbbbb", state: "in_progress" } }] } }); | |
| 59 | + | }, | |
| 60 | + | SCREENSHOTS: { name: "web-deploy-check", entrypoint: "ScreenshotStub" }, | |
| 38 | 61 | }, | |
| 39 | 62 | })); | |
| 40 | 63 | try { | |
| 41 | 64 | const origin = await runtime.ready; | |
| 42 | 65 | const request = (path, init) => fetch(new URL(path, origin), { ...init, signal: AbortSignal.timeout(15_000) }); | |
| 43 | 66 | assert.equal((await request("/__seed")).status, 200); | |
| 67 | + | const screenshot = await request("/acme/public/production.jpg?v=abcdef1234567-" + Date.parse("2026-10-15T01:00:00Z")); | |
| 68 | + | assert.equal(screenshot.status, 200, "Signed-out readers can see public production screenshots"); | |
| 69 | + | assert.equal(screenshot.headers.get("content-type"), "image/jpeg"); | |
| 70 | + | assert.equal(screenshot.headers.get("cache-control"), "private, max-age=31536000, immutable"); | |
| 71 | + | assert.equal(await screenshot.text(), "production-preview"); | |
| 72 | + | assert.equal((await request("/acme/private/production.jpg")).status, 404, "Unreadable projects never expose screenshots"); | |
| 73 | + | console.log("Public production screenshots: anonymous access, private-project denial, and serving commit selection verified."); | |
| 44 | 74 | for (const [path, destination] of [["/developers", "https://docs.g1t.sh/reference/api/"], ["/openapi.json", "https://api.g1t.sh/openapi.json"], ["/mcp", "https://mcp.g1t.sh"]]) { | |
| 45 | 75 | const answer = await request(path, { redirect: "manual" }); | |
| 46 | 76 | assert.equal(answer.status, 308); | |
Comments
Sign in to comment.