Public: anyone can read this; the crew can change it.
A repository's waves are an Atom feed: /:owner/:name/waves.atom #4122
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4112-a-repository-s-waves-are-an-atom-feed-ow → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +116
−4
README.md +4
| @@ -1832,6 +1832,10 @@ work here. | ||
| 1832 | 1832 | terminal, `V` in a repository opens the same list and Enter (or a click) on |
| 1833 | 1833 | a row opens the same wave as plain text (a live browse of another forge says |
| 1834 | 1834 | waves are only read on this forge). |
| 1835 | `/:owner/:name/waves.atom` is the same list as an Atom feed (newest 50, each | |
| 1836 | entry the headline, its highlights and a link to the wave), advertised from | |
| 1837 | the waves page; a private repository's feed is a 404 to anyone who cannot | |
| 1838 | read it. | |
| 1835 | 1839 | |
| 1836 | 1840 | - **Accounts & access** — register/sign in, personal access tokens with coarse |
| 1837 | 1841 | scopes (ro/rw/admin), public and private repositories. |
src/http/server.ts +2 −1
| @@ -206,7 +206,7 @@ import { | ||
| 206 | 206 | branchDelete, |
| 207 | 207 | branchesPage, |
| 208 | 208 | } from "../web/pages/branches.ts"; |
| 209 | import { wavePage, wavesPage } from "../web/pages/waves.ts"; | |
| 209 | import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts"; | |
| 210 | 210 | import { |
| 211 | 211 | contributeSubmit, |
| 212 | 212 | deleteFilePage, |
| @@ -1295,6 +1295,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1295 | 1295 | // The repository's activity, for a feed reader. |
| 1296 | 1296 | router.add("GET", "/:owner/:name/activity.atom", activityAtom); |
| 1297 | 1297 | router.add("GET", "/:owner/:name/branches", branchesPage); |
| 1298 | router.add("GET", "/:owner/:name/waves.atom", wavesAtom); | |
| 1298 | 1299 | router.add("GET", "/:owner/:name/waves", wavesPage); |
| 1299 | 1300 | router.add("GET", "/:owner/:name/waves/:version", wavePage); |
| 1300 | 1301 | router.add("POST", "/:owner/:name/branches", branchCreate); |
src/web/feeds.ts +2 −2
| @@ -52,7 +52,7 @@ export function xml(s: string): string { | ||
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | /** RFC 3339, which Atom requires; the outbox already stores it. */ |
| 55 | const stamp = (iso: string): string => | |
| 55 | export const stamp = (iso: string): string => | |
| 56 | 56 | /Z$|[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`; |
| 57 | 57 | |
| 58 | 58 | /** The branch a push moved, from the one payload field a push always has. */ |
| @@ -65,7 +65,7 @@ function pushedBranch(event: ForgeEvent): string { | ||
| 65 | 65 | } |
| 66 | 66 | |
| 67 | 67 | /** Does an `If-None-Match` header name this tag (weakly, or as `*`)? */ |
| 68 | function holds(header: string | null, etag: string): boolean { | |
| 68 | export function holds(header: string | null, etag: string): boolean { | |
| 69 | 69 | if (header === null) return false; |
| 70 | 70 | return header |
| 71 | 71 | .split(",") |
src/web/pages/waves.ts +70 −1
| @@ -6,13 +6,16 @@ | ||
| 6 | 6 | * it does for any other page of a private repository. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { canRead, getRepo } from "../../core/repos.ts"; | |
| 10 | import { notFound } from "../../http/respond.ts"; | |
| 11 | import { holds, stamp, xml } from "../feeds.ts"; | |
| 9 | 12 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 10 | 13 | import { repoDir } from "../../git/repos.ts"; |
| 11 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 12 | 15 | import { VERSION } from "../../story/wave.ts"; |
| 13 | 16 | import { readWave, readWaves } from "../../story/waves.ts"; |
| 14 | 17 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 15 | import { html } from "../html.ts"; | |
| 18 | import { escapeHtml, html } from "../html.ts"; | |
| 16 | 19 | import { page } from "../layout.ts"; |
| 17 | 20 | import { empty, listCard, row } from "../ui.ts"; |
| 18 | 21 | import { localScope } from "../scope.ts"; |
| @@ -60,11 +63,77 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 60 | 63 | crumbs: scope.crumbs, |
| 61 | 64 | palette: scope.palette, |
| 62 | 65 | body, |
| 66 | feed: { | |
| 67 | href: `${repoBase(repo)}/waves.atom`, | |
| 68 | title: `${repo.ownerName}/${repo.name} waves`, | |
| 69 | }, | |
| 63 | 70 | }); |
| 64 | 71 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 65 | 72 | return response; |
| 66 | 73 | } |
| 67 | 74 | |
| 75 | const FEED_ENTRIES = 50; | |
| 76 | ||
| 77 | // GET /:owner/:name/waves.atom | |
| 78 | export async function wavesAtom(ctx: Ctx): Promise<Response> { | |
| 79 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 80 | const repo = getRepo(ctx.app.db, ctx.params["owner"]!, ctx.params["name"]!); | |
| 81 | if (!repo || !canRead(ctx.app.db, repo, ctx.auth.user)) return notFound(); | |
| 82 | const waves = ( | |
| 83 | await timePhaseAsync(ctx.request, "waves", () => | |
| 84 | readWaves(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch), | |
| 85 | ) | |
| 86 | ) | |
| 87 | .flatMap((e) => (e.ok ? [e.wave] : [])) | |
| 88 | .slice(0, FEED_ENTRIES); | |
| 89 | const base = `${ctx.url.origin}/${repo.ownerName}/${repo.name}`; | |
| 90 | const updatedAt = (date: string): string => stamp(`${date}T00:00:00`); | |
| 91 | // The newest wave names the tag; the content hash covers edits to it. | |
| 92 | const newest = waves[0]; | |
| 93 | const shown = Bun.hash(JSON.stringify([repo.ownerName, repo.name, repo.description, waves])) | |
| 94 | .toString(36); | |
| 95 | const etag = `W/"${newest?.version ?? "none"}.${shown}"`; | |
| 96 | const cacheControl = repo.visibility === "public" ? "public, max-age=300" : "private, no-store"; | |
| 97 | const vary = "authorization, cookie"; | |
| 98 | if (holds(ctx.request.headers.get("if-none-match"), etag)) | |
| 99 | return new Response(null, { status: 304, headers: { etag, "cache-control": cacheControl, vary } }); | |
| 100 | const renderStart = performance.now(); | |
| 101 | const entries = waves.map((w) => { | |
| 102 | const summary = `<ul>${w.highlights | |
| 103 | .map((h) => `<li><strong>${escapeHtml(h.title)}</strong> — ${escapeHtml(h.why)}</li>`) | |
| 104 | .join("")}</ul>`; | |
| 105 | const link = `${base}/waves/${encodeURIComponent(w.version)}`; | |
| 106 | return ` <entry> | |
| 107 | <id>urn:forge:wave:${xml(repo.id)}:${xml(w.version)}</id> | |
| 108 | <title>${xml(w.headline)}</title> | |
| 109 | <updated>${xml(updatedAt(w.date))}</updated> | |
| 110 | <link rel="alternate" type="text/html" href="${xml(link)}"/> | |
| 111 | <summary type="html">${xml(summary)}</summary> | |
| 112 | </entry>`; | |
| 113 | }); | |
| 114 | const body = `<?xml version="1.0" encoding="utf-8"?> | |
| 115 | <feed xmlns="http://www.w3.org/2005/Atom"> | |
| 116 | <id>${xml(base)}/waves.atom</id> | |
| 117 | <title>${xml(`${repo.ownerName}/${repo.name} waves`)}</title> | |
| 118 | <subtitle>${xml(repo.description === "" ? "Waves" : repo.description)}</subtitle> | |
| 119 | <updated>${xml(newest ? updatedAt(newest.date) : new Date(0).toISOString())}</updated> | |
| 120 | <link rel="self" type="application/atom+xml" href="${xml(base)}/waves.atom"/> | |
| 121 | <link rel="alternate" type="text/html" href="${xml(base)}/waves"/> | |
| 122 | <generator>forge</generator> | |
| 123 | ${entries.join("\n")} | |
| 124 | </feed> | |
| 125 | `; | |
| 126 | notePhase(ctx.request, "render", performance.now() - renderStart); | |
| 127 | return new Response(body, { | |
| 128 | headers: { | |
| 129 | "content-type": "application/atom+xml; charset=utf-8", | |
| 130 | "cache-control": cacheControl, | |
| 131 | etag, | |
| 132 | vary, | |
| 133 | }, | |
| 134 | }); | |
| 135 | } | |
| 136 | ||
| 68 | 137 | /** Only http(s) becomes a link; anything else is shown as plain text. */ |
| 69 | 138 | export const webUrl = (s: string): boolean => /^https?:\/\//i.test(s); |
| 70 | 139 |
test/waves-page.test.ts +38
| @@ -170,4 +170,42 @@ describe("waves page", () => { | ||
| 170 | 170 | expect(await out.text()).not.toContain("Secret launch"); |
| 171 | 171 | expect(await (await get("/alice/priv/waves/1.0.0")).text()).toContain("Secret launch"); |
| 172 | 172 | }); |
| 173 | ||
| 174 | test("waves.atom is Atom, one entry per wave, newest first", async () => { | |
| 175 | const res = await get("/alice/pub/waves.atom"); | |
| 176 | expect(res.status).toBe(200); | |
| 177 | expect(res.headers.get("content-type")).toContain("application/atom+xml"); | |
| 178 | const body = await res.text(); | |
| 179 | expect(body.match(/<entry>/g)?.length).toBe(4); | |
| 180 | expect(body.indexOf("Tenth")).toBeLessThan(body.indexOf("Ninth")); | |
| 181 | expect(body).toContain('href="http://'); | |
| 182 | expect(body).toContain("/alice/pub/waves/0.10.0"); | |
| 183 | }); | |
| 184 | ||
| 185 | test("waves.atom escapes markup in headlines and notes", async () => { | |
| 186 | const body = await (await get("/alice/pub/waves.atom")).text(); | |
| 187 | expect(body).not.toContain("<script>"); | |
| 188 | expect(body).not.toContain("<b>"); | |
| 189 | expect(body).toContain("&lt;u&gt;one&lt;/u&gt;"); | |
| 190 | }); | |
| 191 | ||
| 192 | test("waves.atom of a private repository is a 404 to a stranger", async () => { | |
| 193 | expect((await get("/alice/priv/waves.atom", false)).status).toBe(404); | |
| 194 | expect((await get("/alice/priv/waves.atom")).status).toBe(200); | |
| 195 | }); | |
| 196 | ||
| 197 | test("waves.atom answers a repeat with 304", async () => { | |
| 198 | const first = await get("/alice/pub/waves.atom"); | |
| 199 | const etag = first.headers.get("etag")!; | |
| 200 | const again = await fetch(`${servers.url}/alice/pub/waves.atom`, { | |
| 201 | headers: { cookie, "if-none-match": etag }, | |
| 202 | }); | |
| 203 | expect(again.status).toBe(304); | |
| 204 | }); | |
| 205 | ||
| 206 | test("the waves page advertises the feed", async () => { | |
| 207 | const body = await (await get("/alice/pub/waves")).text(); | |
| 208 | expect(body).toContain('href="/alice/pub/waves.atom"'); | |
| 209 | expect(body).toContain("application/atom+xml"); | |
| 210 | }); | |
| 173 | 211 | }); |
new above
offline
Good: this is the same gate as
activityAtom, and it answers an outsider with the same 404 as a missing repository, so the feed doesn't reveal that a private repository exists.