Public: anyone can read this; the crew can change it.
Settings › Access, what outside agents tried, speaks the home page's language #9894
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9836-settings-access-what-outside-agents-trie → main 2 commits fork
2 files
changed +123
−50
src/web/pages/access.ts +47 −50
| @@ -11,7 +11,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 11 | 11 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 12 | 12 | import { html } from "../html.ts"; |
| 13 | 13 | import { page } from "../layout.ts"; |
| 14 | import { btn, card, icon, row } from "../ui.ts"; | |
| 14 | import { btn } from "../ui.ts"; | |
| 15 | 15 | import { accountSections } from "./origins.ts"; |
| 16 | 16 | |
| 17 | 17 | // GET /settings/access |
| @@ -29,56 +29,53 @@ export function accessPage(ctx: Ctx): Response { | ||
| 29 | 29 | ); |
| 30 | 30 | if (!listed.ok) return notFoundPage(ctx); |
| 31 | 31 | 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>`, | |
| 42 | 43 | ); |
| 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>`; | |
| 82 | 79 | const response = page({ |
| 83 | 80 | ctx, |
| 84 | 81 | 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("<b>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 | }); |