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

Settings › Access, what outside agents tried, speaks the home page's language #9894

Merged opened this

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

Merged

mason/forge:mason/9836-settings-access-what-outside-agents-trie → main 2 commits fork

  • Merged. mason/9836-settings-access-what-outside-agents-trie was merged into main as cf2b8c7.

Review

Sign in to review this change.
2 files changed +123 −50
src/web/pages/access.ts +47 −50
@@ -11,7 +11,7 @@ import type { Ctx } from "../../http/context.ts";
1111 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1212 import { html } from "../html.ts";
1313 import { page } from "../layout.ts";
14 import { btn, card, icon, row } from "../ui.ts";
14 import { btn } from "../ui.ts";
1515 import { accountSections } from "./origins.ts";
1616
1717 // GET /settings/access
@@ -29,56 +29,53 @@ export function accessPage(ctx: Ctx): Response {
2929 );
3030 if (!listed.ok) return notFoundPage(ctx);
3131 const rendering = performance.now();
32 const rows = listed.value.map((l) =>
33 row({
34 lead: icon("activity"),
35 title: html`<span class="mono">${l.method} ${l.path}</span>
36 <span class="mono">${String(l.status)}</span>`,
37 meta: html`${l.t} · ${l.host} · ${String(l.ms)} ms ·
38 ${l.auth ? "signed in" : "anonymous"} ·
39 <span class="mono">${l.who}</span> ·
40 ${l.ua === "" ? "no user agent" : l.ua}`,
41 }),
32 const rows = listed.value.map(
33 (l) => html`<tr>
34 <td class="break"><span class="mono">${l.method} ${l.path}</span></td>
35 <td class="num mono">${String(l.status)}</td>
36 <td class="small">${l.t}</td>
37 <td class="small">${l.host}</td>
38 <td class="num small">${String(l.ms)} ms</td>
39 <td class="small">${l.auth ? "signed in" : "anonymous"}</td>
40 <td class="mono small">${l.who}</td>
41 <td class="break small">${l.ua === "" ? "no user agent" : l.ua}</td>
42 </tr>`,
4243 );
43 const body = html`${card(
44 html`<div class="card-body">
45 <p class="muted small">
46 The last day's requests from anything that is not a browser, to
47 public paths (issues, the API, MCP, discovery files, sign-in and
48 sign-up). Tokens, cookies and query strings are never kept; the
49 hash stands for one caller for one day.
50 </p>
51 <form class="row-fields" method="get" action="/settings/access">
52 <input
53 class="input"
54 name="ua"
55 value="${ua}"
56 placeholder="User agent contains"
57 aria-label="User agent contains"
58 />
59 <input
60 class="input mono"
61 name="path"
62 value="${path}"
63 placeholder="/api/"
64 aria-label="Path starts with"
65 />
66 ${btn("Filter", { size: "sm", variant: "primary" })}
67 </form>
68 </div>
69 ${rows.length === 0
70 ? html`<div class="card-body">
71 <p class="muted small" id="access-none">Nothing in the last day.</p>
72 </div>`
73 : html`<ul class="list" id="access-list">
74 ${rows}
75 </ul>`}`,
76 {
77 head: html`${icon("activity")}
78 <h2>Outside requests</h2>`,
79 pad: false,
80 },
81 )}`;
44 const body = html`<section class="settings-block">
45 <h2>Outside requests</h2>
46 <p class="muted small">
47 The last day's requests from anything that is not a browser, to public
48 paths (issues, the API, MCP, discovery files, sign-in and sign-up).
49 Tokens, cookies and query strings are never kept; the hash stands for one
50 caller for one day.
51 </p>
52 <form class="row-fields" method="get" action="/settings/access">
53 <input
54 class="input"
55 name="ua"
56 value="${ua}"
57 placeholder="User agent contains"
58 aria-label="User agent contains"
59 />
60 <input
61 class="input mono"
62 name="path"
63 value="${path}"
64 placeholder="/api/"
65 aria-label="Path starts with"
66 />
67 ${btn("Filter", { size: "sm", variant: "primary" })}
68 </form>
69 ${rows.length === 0
70 ? html`<p class="muted small" id="access-none">Nothing in the last day.</p>`
71 : html`<div class="table-scroll">
72 <table class="table" id="access-list" data-dense>
73 <caption class="sr-only">Requests from outside agents in the last day</caption>
74 <thead><tr><th scope="col">Request</th><th scope="col" class="num">Status</th><th scope="col">Time</th><th scope="col">Host</th><th scope="col" class="num">Took</th><th scope="col">Caller</th><th scope="col">Hash</th><th scope="col">User agent</th></tr></thead>
75 <tbody>${rows}</tbody>
76 </table>
77 </div>`}
78 </section>`;
8279 const response = page({
8380 ctx,
8481 title: "Access",
test/access-language.test.ts added +76
@@ -0,0 +1,76 @@
1 /** Settings › Access speaks the home page's language (#9836): one heading, the filter form, a plain table, no card. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { serve } from "../src/http/server.ts";
9
10 let dir = "";
11 let app: ReturnType<typeof createApp>;
12 let servers: Awaited<ReturnType<typeof serve>>;
13 const cookies: Record<string, string> = {};
14
15 beforeAll(async () => {
16 dir = await mkdtemp(join(tmpdir(), "forge-access-lang-"));
17 app = createApp({ dataDir: dir, port: 0 });
18 servers = await serve(app);
19 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
20 await createUser(app.db, {
21 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
22 password: "a-good-passphrase", adminIfFirst: first,
23 });
24 const res = await fetch(servers.url + "/signin", {
25 method: "POST",
26 redirect: "manual",
27 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
28 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
29 });
30 cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
31 }
32 });
33 afterAll(async () => {
34 servers.stop();
35 app.close();
36 await rm(dir, { recursive: true, force: true });
37 });
38
39 const main = (page: string): string => page.slice(page.indexOf("<main"), page.indexOf("</main>"));
40
41 async function access(query = ""): Promise<string> {
42 const res = await fetch(servers.url + "/settings/access" + query, { headers: { cookie: cookies.alice! } });
43 expect(res.status).toBe(200);
44 return main(await res.text());
45 }
46
47 function plain(page: string): void {
48 expect(page.match(/<h1[\s>]/g)?.length).toBe(1);
49 expect(page).not.toContain('class="card');
50 expect(page).not.toContain("card-body");
51 expect(page).toContain('action="/settings/access"');
52 expect(page).toContain('name="ua"');
53 expect(page).toContain('name="path"');
54 }
55
56 test("nothing logged for the filter: the filter and the empty sentence, no card", async () => {
57 // Signing in is itself logged, so an empty log is reached by a filter that matches nothing.
58 const page = await access("?ua=no-such-agent");
59 plain(page);
60 expect(page).toContain("Nothing in the last day.");
61 });
62
63 test("one logged request: the filter and a table row, escaped, no card", async () => {
64 await fetch(servers.url + "/api/v1/lang-probe", { headers: { "user-agent": "<b>probe</b>-bot" } });
65 const page = await access();
66 plain(page);
67 expect(page).toContain("<table");
68 expect(page).toContain("/api/v1/lang-probe");
69 expect(page).not.toContain("<b>probe</b>");
70 expect(page).toContain("&lt;b&gt;probe");
71 });
72
73 test("a non-admin still gets 404", async () => {
74 const res = await fetch(servers.url + "/settings/access", { headers: { cookie: cookies.bob! } });
75 expect(res.status).toBe(404);
76 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs