Public: anyone can read this; the crew can change it.

A repository's waves are an Atom feed: /:owner/:name/waves.atom #4122

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4112-a-repository-s-waves-are-an-atom-feed-ow → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/4112-a-repository-s-waves-are-an-atom-feed-ow was merged into main as 044bf70.

Review

Sign in to review this change.
5 files changed +116 −4
README.md +4
@@ -1832,6 +1832,10 @@ work here.
18321832 terminal, `V` in a repository opens the same list and Enter (or a click) on
18331833 a row opens the same wave as plain text (a live browse of another forge says
18341834 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.
18351839
18361840 - **Accounts & access** — register/sign in, personal access tokens with coarse
18371841 scopes (ro/rw/admin), public and private repositories.
src/http/server.ts +2 −1
@@ -206,7 +206,7 @@ import {
206206 branchDelete,
207207 branchesPage,
208208 } from "../web/pages/branches.ts";
209 import { wavePage, wavesPage } from "../web/pages/waves.ts";
209 import { wavePage, wavesAtom, wavesPage } from "../web/pages/waves.ts";
210210 import {
211211 contributeSubmit,
212212 deleteFilePage,
@@ -1295,6 +1295,7 @@ export function buildRouter(): Router<Handler> {
12951295 // The repository's activity, for a feed reader.
12961296 router.add("GET", "/:owner/:name/activity.atom", activityAtom);
12971297 router.add("GET", "/:owner/:name/branches", branchesPage);
1298 router.add("GET", "/:owner/:name/waves.atom", wavesAtom);
12981299 router.add("GET", "/:owner/:name/waves", wavesPage);
12991300 router.add("GET", "/:owner/:name/waves/:version", wavePage);
13001301 router.add("POST", "/:owner/:name/branches", branchCreate);
src/web/feeds.ts +2 −2
@@ -52,7 +52,7 @@ export function xml(s: string): string {
5252 }
5353
5454 /** RFC 3339, which Atom requires; the outbox already stores it. */
55 const stamp = (iso: string): string =>
55 export const stamp = (iso: string): string =>
5656 /Z$|[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`;
5757
5858 /** The branch a push moved, from the one payload field a push always has. */
@@ -65,7 +65,7 @@ function pushedBranch(event: ForgeEvent): string {
6565 }
6666
6767 /** 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 {
6969 if (header === null) return false;
7070 return header
7171 .split(",")
src/web/pages/waves.ts +70 −1
@@ -6,13 +6,16 @@
66 * it does for any other page of a private repository.
77 */
88
9 import { canRead, getRepo } from "../../core/repos.ts";
10 import { notFound } from "../../http/respond.ts";
11 import { holds, stamp, xml } from "../feeds.ts";
912 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1013 import { repoDir } from "../../git/repos.ts";
1114 import type { Ctx } from "../../http/context.ts";
1215 import { VERSION } from "../../story/wave.ts";
1316 import { readWave, readWaves } from "../../story/waves.ts";
1417 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
15 import { html } from "../html.ts";
18 import { escapeHtml, html } from "../html.ts";
1619 import { page } from "../layout.ts";
1720 import { empty, listCard, row } from "../ui.ts";
1821 import { localScope } from "../scope.ts";
@@ -60,11 +63,77 @@ export async function wavesPage(ctx: Ctx): Promise<Response> {
6063 crumbs: scope.crumbs,
6164 palette: scope.palette,
6265 body,
66 feed: {
67 href: `${repoBase(repo)}/waves.atom`,
68 title: `${repo.ownerName}/${repo.name} waves`,
69 },
6370 });
6471 notePhase(ctx.request, "render", performance.now() - rendering);
6572 return response;
6673 }
6774
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();

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.

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
68137 /** Only http(s) becomes a link; anything else is shown as plain text. */
69138 export const webUrl = (s: string): boolean => /^https?:\/\//i.test(s);
70139
test/waves-page.test.ts +38
@@ -170,4 +170,42 @@ describe("waves page", () => {
170170 expect(await out.text()).not.toContain("Secret launch");
171171 expect(await (await get("/alice/priv/waves/1.0.0")).text()).toContain("Secret launch");
172172 });
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("&amp;lt;u&amp;gt;one&amp;lt;/u&amp;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 });
173211 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs