Public: anyone can read this; the crew can change it.
The app's owner hides a note or bans its sender from a screen, not only through `POST /__forge/note/moderate` #5100
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4819-the-app-s-owner-hides-a-note-or-bans-its → main 4 commits fork 7 threads · 7 unresolved
README.md +7 −3
| @@ -1004,7 +1004,8 @@ Under the box, four starter sentences are plain links (no script): | ||
| 1004 | 1004 | From the terminal, a repository screen that is an app has two palette rows |
| 1005 | 1005 | (`:`): *Tell the builders* sends a note as you, and *Notes from the app* |
| 1006 | 1006 | lists yours with their status; the repository's admin sees every sender's and |
| 1007 | may hide a note or ban its sender. The gating is the page's: a guest or a | |
| 1007 | may hide a note, ban its sender, list the banned senders (*Banned senders*) | |
| 1008 | and unban one. The gating is the page's: a guest or a | |
| 1008 | 1009 | non-reader sees nothing, and a repository that is not an app has no rows. |
| 1009 | 1010 | Who may write: anyone on a public app, but a private app, and any sealed |
| 1010 | 1011 | pull's preview, take notes only from a person the app admits (the condition |
| @@ -1013,8 +1014,11 @@ Under the box, four starter sentences are plain links (no script): | ||
| 1013 | 1014 | happens on the forge, not on the app: the app host answers only |
| 1014 | 1015 | `POST /__forge/note` and `GET /__forge/notes`, and `/__forge/note/moderate` is |
| 1015 | 1016 | a 404 there, because the app's own code (or a preview's) could call it |
| 1016 | same-origin with a visiting admin's cookie. The terminal screen above is the | |
| 1017 | owner's tool today. | |
| 1017 | same-origin with a visiting admin's cookie. The terminal screen above and the | |
| 1018 | app's studio page are the owner's tools: the page lists the latest notes | |
| 1019 | (sender, words, page, when) for those who may admin the repository, each with | |
| 1020 | Hide and Ban sender, and every banned sender once with Unban, posting to | |
| 1021 | `POST /:owner/:name/app/notes` on the forge's origin (`web/studio/notes.ts`). | |
| 1018 | 1022 | Limits count the socket's client (`http/peer.ts`, with its trusted-proxy |
| 1019 | 1023 | rule), never a header the client wrote, and all three are checked before any |
| 1020 | 1024 | is spent. The request body is read only up to 16 KB. The issue says its text |
src/actions/app_notes.ts +49 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * both go through here, so the limits and the gating are one set. |
| 7 | 7 | */ |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | import { banVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts"; | |
| 9 | import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts"; | |
| 10 | 10 | import type { AppRecord } from "../core/apps.ts"; |
| 11 | 11 | import { createLabel, getIssue, getLabel } from "../core/issues.ts"; |
| 12 | 12 | import { RateLimiter } from "../core/ratelimit.ts"; |
| @@ -259,3 +259,51 @@ export async function moderateNote( | ||
| 259 | 259 | if (action === "ban") banVisitor(app.db, record.id, note.visitor); |
| 260 | 260 | return ok(true); |
| 261 | 261 | } |
| 262 | ||
| 263 | /** What the owner's list shows of one note: the sender, the words, the page. */ | |
| 264 | export interface OwnerNote { | |
| 265 | readonly id: string; | |
| 266 | readonly by: string; | |
| 267 | readonly person: boolean; | |
| 268 | readonly text: string; | |
| 269 | readonly page: string; | |
| 270 | readonly at: string; | |
| 271 | readonly banned: boolean; | |
| 272 | } | |
| 273 | ||
| 274 | /** The page a note was sent from, read back from the fenced line the issue holds. */ | |
| 275 | function pageOf(body: string): string { | |
| 276 | const m = /^- Page: `([^`\n]*)`/m.exec(body); | |
| 277 | return m?.[1] ?? ""; | |
| 278 | } | |
| 279 | ||
| 280 | function ownerView(app: App, repo: Repo, n: AppNote, banned: boolean): OwnerNote | undefined { | |
| 281 | const issue = getIssue(app.db, repo.id, n.issueNumber); | |
| 282 | if (issue === undefined) return undefined; | |
| 283 | return { | |
| 284 | id: n.id, | |
| 285 | by: n.name, | |
| 286 | person: n.visitor.startsWith("u:"), | |
| 287 | text: issue.title.startsWith(FROM_APP_TITLE) ? issue.title.slice(FROM_APP_TITLE.length) : issue.title, | |
| 288 | page: pageOf(issue.body), | |
| 289 | at: n.createdAt, | |
| 290 | banned, | |
| 291 | }; | |
| 292 | } | |
| 293 | ||
Low: the doc says "Writers only", but the gate is |
||
| 294 | /** The owner's screen: the latest notes still shown, and each banned sender once. Writers only. */ | |
| 295 | export function ownerNotes(app: App, record: AppRecord, repo: Repo, actor: User | undefined): { notes: OwnerNote[]; banned: OwnerNote[] } | undefined { | |
High (test gap): this |
||
| 296 | if (actor === undefined || !canAdmin(app.db, repo, actor)) return undefined; | |
| 297 | const pick = (rows: AppNote[], banned: boolean) => | |
| 298 | rows.map((n) => ownerView(app, repo, n, banned)).filter((n): n is OwnerNote => n !== undefined); | |
| 299 | return { notes: pick(notesOfApp(app.db, record.id), false), banned: pick(bannedNotes(app.db, record.id), true) }; | |
| 300 | } | |
| 301 | ||
| 302 | /** The owner's: let the sender of a note write again. */ | |
| 303 | export function unbanSender(app: App, record: AppRecord, repo: Repo, actor: User | undefined, noteId: string): Result<true, "forbidden" | "not_found"> { | |
Only the web page calls |
||
| 304 | if (actor === undefined || !canAdmin(app.db, repo, actor)) return err("forbidden"); | |
High (test gap): nothing would fail if this check went. Add a web POST of |
||
| 305 | const note = getNote(app.db, record.id, noteId); | |
| 306 | if (note === undefined) return err("not_found"); | |
| 307 | unbanVisitor(app.db, record.id, note.visitor); | |
| 308 | return ok(true); | |
| 309 | } |
src/core/app_notes.ts +23
| @@ -100,6 +100,29 @@ export function banVisitor(db: Database, appId: string, visitor: string): void { | ||
| 100 | 100 | db.query("UPDATE app_note SET hidden = 1 WHERE app_id = ?1 AND visitor = ?2").run(appId, visitor); |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | /** Let a banned sender write again; the notes hidden with the ban stay hidden. */ | |
| 104 | export function unbanVisitor(db: Database, appId: string, visitor: string): void { | |
| 105 | db.query("DELETE FROM app_note_ban WHERE app_id = ?1 AND visitor = ?2").run(appId, visitor); | |
| 106 | } | |
| 107 | ||
| 108 | /** | |
| 109 | * Each banned sender once, newest ban first, as their latest note (hidden or | |
| 110 | * not): the note's id is how the owner names them, never the visitor key. | |
| 111 | */ | |
| 112 | export function bannedNotes(db: Database, appId: string, limit = 50): AppNote[] { | |
| 113 | return db | |
| 114 | .query<Row, [string, number]>( | |
| 115 | `SELECT n.* FROM app_note_ban b | |
| 116 | JOIN app_note n ON n.app_id = b.app_id AND n.visitor = b.visitor | |
| 117 | WHERE b.app_id = ?1 | |
| 118 | AND n.id = (SELECT m.id FROM app_note m WHERE m.app_id = b.app_id AND m.visitor = b.visitor | |
| 119 | ORDER BY m.created_at DESC, m.id DESC LIMIT 1) | |
| 120 | ORDER BY b.created_at DESC, n.id DESC LIMIT ?2`, | |
outdated
Medium: there's no LIMIT, so the studio page renders every banned sender on each admin view, with a correlated subquery and a getIssue per row. Add |
||
| 121 | ) | |
| 122 | .all(appId, limit) | |
| 123 | .map(fromRow); | |
| 124 | } | |
| 125 | ||
| 103 | 126 | export function isBanned(db: Database, appId: string, visitor: string): boolean { |
| 104 | 127 | return ( |
| 105 | 128 | db |
src/http/server.ts +2
| @@ -226,6 +226,7 @@ import { | ||
| 226 | 226 | newIssuePage, |
| 227 | 227 | newIssueSubmit, |
| 228 | 228 | } from "../web/pages/issues.ts"; |
| 229 | import { noteModerate } from "../web/studio/notes.ts"; | |
| 229 | 230 | import { tellPage } from "../web/studio/tell.ts"; |
| 230 | 231 | import { tellHerePage } from "../web/studio/tell_here.ts"; |
| 231 | 232 | import { globalMapPage, mapPage } from "../web/pages/map.ts"; |
| @@ -1234,6 +1235,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1234 | 1235 | router.add("GET", "/:owner/:name/app/:env/open", appOpen); |
| 1235 | 1236 | // Where a private app sends a person who arrived by its link. |
| 1236 | 1237 | router.add("GET", "/apps/open", appsOpen); |
| 1238 | router.add("POST", "/:owner/:name/app/notes", noteModerate); | |
| 1237 | 1239 | router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy); |
| 1238 | 1240 | router.add("POST", "/:owner/:name/app/:env/stop", appStop); |
| 1239 | 1241 | router.add("GET", "/:owner/:name/runs", runsList); |
src/tui/screens/notes.ts +37 −2
| @@ -3,11 +3,11 @@ | ||
| 3 | 3 | * the terminal's door to what the button does on an app's page, with the |
| 4 | 4 | * page's gating. A signed-in person who reads the repository may send a note |
| 5 | 5 | * and see their own with its status; the repository's admin sees every |
| 6 | * sender's and may hide a note or ban its sender. A repository that is not | |
| 6 | * sender's and may hide a note or ban its sender, see the banned senders and unban them. A repository that is not | |
Low: this header line runs well past the width of the block around it. Rewrap it to match the surrounding comment. |
||
| 7 | 7 | * an app, or a guest, gets no rows. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { moderateNote, notesFor, sendNote, type Sender } from "../../actions/app_notes.ts"; | |
| 10 | import { moderateNote, notesFor, ownerNotes, sendNote, unbanSender, type Sender } from "../../actions/app_notes.ts"; | |
| 11 | 11 | import { getAppByRepo } from "../../core/apps.ts"; |
| 12 | 12 | import { canAdmin, canEnterApp, canRead, type Repo } from "../../core/repos.ts"; |
| 13 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -24,6 +24,40 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R | ||
| 24 | 24 | const name = `${repo.ownerName}/${repo.name}`; |
| 25 | 25 | const sender: Sender = { key: `u:${user.id}`, name: `@${user.username}`, person: true }; |
| 26 | 26 | const admin = canAdmin(f.app.db, repo, user); |
| 27 | const unban: Command[] = []; | |
| 28 | if (admin) { | |
| 29 | unban.push({ | |
| 30 | id: "banned", | |
| 31 | title: `${name} — Banned senders`, | |
| 32 | hint: "unban a sender", | |
| 33 | run: () => { | |
| 34 | const banned = ownerNotes(f.app, record, repo, user)?.banned ?? []; | |
| 35 | if (banned.length === 0) return ctx.toast("No one is banned."); | |
| 36 | ctx.push( | |
| 37 | pickerScreen(ctx, parent(), { | |
| 38 | title: "Banned senders", | |
| 39 | items: banned.map((n) => ({ id: n.id, label: n.by, hint: n.text })), | |
| 40 | onPick: ([id]) => { | |
| 41 | const picked = banned.find((n) => n.id === id); | |
| 42 | if (picked === undefined) return; | |
| 43 | ctx.push( | |
| 44 | confirmScreen(ctx, parent(), { | |
| 45 | title: "Unban this sender", | |
| 46 | body: [picked.by, picked.text], | |
| 47 | actions: [{ label: "Keep banned", variant: "ghost" }, { label: "Unban" }], | |
| 48 | onChoose: (i) => { | |
| 49 | if (i < 1) return; | |
| 50 | const done = unbanSender(f.app, record, repo, user, picked.id); | |
| 51 | ctx.toast(done.ok ? "Sender unbanned." : "That could not be done.", done.ok ? "success" : "danger"); | |
| 52 | }, | |
| 53 | }), | |
| 54 | ); | |
| 55 | }, | |
| 56 | }), | |
| 57 | ); | |
| 58 | }, | |
| 59 | }); | |
| 60 | } | |
| 27 | 61 | return [ |
| 28 | 62 | { |
| 29 | 63 | id: "note", |
| @@ -77,5 +111,6 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R | ||
| 77 | 111 | ); |
| 78 | 112 | }, |
| 79 | 113 | }, |
| 114 | ...unban, | |
| 80 | 115 | ]; |
| 81 | 116 | } |
src/web/studio/app.ts +2
| @@ -51,6 +51,7 @@ import { changesHref, tellsHref } from "./nouns.ts"; | ||
| 51 | 51 | import { answersRegion } from "./answers.ts"; |
| 52 | 52 | import { nowCard, nowRegion } from "./now.ts"; |
| 53 | 53 | import { repoAsks } from "./asks.ts"; |
| 54 | import { notesCard } from "./notes.ts"; | |
| 54 | 55 | import { appPanels } from "./previews.ts"; |
| 55 | 56 | import { noCrewLine, servingHistory, type WordCtx } from "./words.ts"; |
| 56 | 57 | import { crewKeyNote } from "./crew.ts"; |
| @@ -500,6 +501,7 @@ export async function studioApp( | ||
| 500 | 501 | ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null} |
| 501 | 502 | <div id="app-previews">${previews}</div> |
| 502 | 503 | ${nowRegion(repo, now)} |
| 504 | ${notesCard(ctx, repo, record)} | |
| 503 | 505 | </div> |
| 504 | 506 | <aside class="stack"> |
| 505 | 507 | ${crewCard} ${weekCard} ${ownCard} ${more} |
src/web/studio/notes.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** | |
| 2 | * The app's notes from its forge button (#4758), on the studio page, for the | |
| 3 | * people who may moderate: the latest `from-app` notes with Hide and Ban | |
| 4 | * sender, and each banned sender once with Unban. The buttons post to the | |
| 5 | * forge's own origin (`POST /:owner/:name/app/notes`), never to the app's | |
| 6 | * host, where moderation is a 404. | |
| 7 | */ | |
| 8 | import { moderateNote, ownerNotes, unbanSender, type OwnerNote } from "../../actions/app_notes.ts"; | |
| 9 | import { getAppByRepo, type AppRecord } from "../../core/apps.ts"; | |
| 10 | import type { Repo } from "../../core/repos.ts"; | |
| 11 | import type { User } from "../../core/users.ts"; | |
| 12 | import type { Ctx } from "../../http/context.ts"; | |
| 13 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; | |
| 14 | import { readForm } from "../form.ts"; | |
| 15 | import { html, type Html } from "../html.ts"; | |
| 16 | import { flashTo } from "../layout.ts"; | |
| 17 | import { badge, btn, icon, listCard, row, time } from "../ui.ts"; | |
| 18 | import { safeBack } from "../../http/context.ts"; | |
| 19 | ||
| 20 | const act = (base: string, n: OwnerNote, action: "hide" | "ban" | "unban", label: string, danger = false): Html => | |
| 21 | html`<form method="post" action="${base}/app/notes" class="inline"> | |
| 22 | <input type="hidden" name="id" value="${n.id}" /> | |
| 23 | <input type="hidden" name="action" value="${action}" /> | |
| 24 | ${btn(label, { size: "sm", ...(danger ? { variant: "danger" as const } : {}) })} | |
| 25 | </form>`; | |
| 26 | ||
| 27 | const sender = (n: OwnerNote): Html => | |
| 28 | html`${n.by} ${badge(n.person ? "signed in" : "anonymous", { variant: "outline" })}`; | |
| 29 | ||
| 30 | /** The card, or nothing for anyone who may not moderate or when there is nothing to show. */ | |
| 31 | export function notesCard(ctx: Ctx, repo: Repo, record: AppRecord): Html | null { | |
| 32 | const seen = ownerNotes(ctx.app, record, repo, ctx.auth.user); | |
| 33 | if (seen === undefined || (seen.notes.length === 0 && seen.banned.length === 0)) return null; | |
| 34 | const base = repoBase(repo); | |
| 35 | const notes = seen.notes.map((n) => | |
| 36 | row({ | |
| 37 | title: n.text, | |
| 38 | meta: html`${sender(n)} · ${n.page === "" ? "no page" : n.page} · ${time(n.at)}`, | |
| 39 | trail: html`<span class="bar">${act(base, n, "hide", "Hide")} ${act(base, n, "ban", "Ban sender", true)}</span>`, | |
| 40 | }), | |
| 41 | ); | |
| 42 | const banned = seen.banned.map((n) => | |
| 43 | row({ | |
| 44 | title: sender(n), | |
| 45 | meta: html`banned · last wrote ${time(n.at)}`, | |
| 46 | trail: act(base, n, "unban", "Unban"), | |
| 47 | }), | |
| 48 | ); | |
| 49 | return html`<div class="stack" id="app-notes"> | |
| 50 | ${listCard(notes.length > 0 ? notes : [row({ title: html`<span class="muted">No notes shown.</span>` })], { | |
| 51 | head: html`${icon("comment")}<h2>Notes from the app</h2>`, | |
| 52 | })} | |
| 53 | ${banned.length === 0 ? null : listCard(banned, { head: html`<h2>Banned senders</h2>` })} | |
| 54 | </div>`; | |
| 55 | } | |
| 56 | ||
| 57 | const FAILURE: Record<string, string> = { | |
| 58 | forbidden: "You need admin access to do that.", | |
| 59 | unavailable: "That could not be done just now.", | |
| 60 | }; | |
| 61 | ||
| 62 | // POST /:owner/:name/app/notes — hide a note, ban or unban its sender. | |
| 63 | export async function noteModerate(ctx: Ctx): Promise<Response> { | |
| 64 | const repo = loadRepo(ctx); | |
| 65 | if (!repo) return notFoundPage(ctx); | |
| 66 | const record = getAppByRepo(ctx.app.db, repo.id); | |
| 67 | if (record === undefined) return notFoundPage(ctx); | |
| 68 | const user: User | undefined = ctx.auth.user; | |
| 69 | if (!user) return signInFirst(ctx); | |
| 70 | const form = await readForm(ctx, "rw"); | |
| 71 | if (form instanceof Response) return form; | |
| 72 | const id = String(form.get("id") ?? ""); | |
| 73 | const action = String(form.get("action") ?? ""); | |
| 74 | if (id === "" || id.length > 64 || !["hide", "ban", "unban"].includes(action)) | |
| 75 | return new Response("bad request", { status: 400 }); | |
| 76 | const done = | |
| 77 | action === "unban" | |
| 78 | ? unbanSender(ctx.app, record, repo, user, id) | |
| 79 | : await moderateNote(ctx.app, record, repo, user, id, action as "hide" | "ban"); | |
| 80 | const back = safeBack(ctx) ?? repoBase(repo); | |
| 81 | if (!done.ok) { | |
| 82 | if (done.error === "not_found") return notFoundPage(ctx); | |
| 83 | return flashTo(ctx, back, { tone: "danger", message: FAILURE[done.error] ?? "That could not be done." }); | |
| 84 | } | |
| 85 | return flashTo(ctx, back, { | |
| 86 | message: action === "hide" ? "Note hidden." : action === "ban" ? "Sender banned." : "Sender unbanned.", | |
| 87 | }); | |
| 88 | } |
test/apps-feedback.test.ts +106 −1
| @@ -15,7 +15,7 @@ import { toolbarJs } from "../src/apps/toolbar.ts"; | ||
| 15 | 15 | import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts"; |
| 16 | 16 | import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts"; |
| 17 | 17 | import { getAppBySlug } from "../src/core/apps.ts"; |
| 18 | import { getNote } from "../src/core/app_notes.ts"; | |
| 18 | import { getNote, isBanned } from "../src/core/app_notes.ts"; | |
| 19 | 19 | import { grantedCookie } from "./helpers/identity.ts"; |
| 20 | 20 | import { getIssue, labelsFor, setIssueState } from "../src/core/issues.ts"; |
| 21 | 21 | import { getRepo } from "../src/core/repos.ts"; |
| @@ -312,6 +312,111 @@ describe("the forge button takes notes from anyone", () => { | ||
| 312 | 312 | expect((await at("diary", "/__forge/notes")).status).toBe(404); |
| 313 | 313 | }); |
| 314 | 314 | |
| 315 | test("the studio page lists the notes; a writer hides one, bans a sender and unbans them; a stranger sees and can do nothing", async () => { | |
| 316 | // The minute's bounds are spent by the tests before; this one starts a later minute. | |
| 317 | const start = Date.now() + 120_000; | |
| 318 | noteClock.now = () => start; | |
| 319 | const signin = await fetch(`${servers.url}/signin`, { | |
| 320 | method: "POST", | |
| 321 | headers: { origin: servers.url }, | |
| 322 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 323 | redirect: "manual", | |
| 324 | }); | |
| 325 | const me = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 326 | const forge = (path: string, init: RequestInit = {}, cookie = me) => | |
| 327 | fetch(`${servers.url}${path}`, { | |
| 328 | ...init, | |
| 329 | headers: { origin: servers.url, ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) }, | |
| 330 | redirect: "manual", | |
| 331 | }); | |
| 332 | const form = (body: Record<string, string>, cookie = me) => | |
| 333 | forge("/alice/notes/app/notes", { method: "POST", body: new URLSearchParams(body) }, cookie); | |
| 334 | const first = await post("studio words one", "", "10.7.0.1"); | |
| 335 | const cookie = cookieOf(first); | |
| 336 | const sent = (await first.json()) as { note: { id: string; url: string }; name: string }; | |
| 337 | const number = Number(sent.note.url.split("/").pop()); | |
| 338 | const repo = getRepo(app.db, "alice", "notes")!; | |
| 339 | ||
| 340 | const page = await (await forge("/alice/notes")).text(); | |
| 341 | expect(page).toContain("studio words one"); | |
| 342 | expect(page).toContain(sent.name); | |
| 343 | expect(page).toContain("anonymous"); | |
| 344 | expect(page).toContain("/lists"); | |
| 345 | expect(page).toContain("Hide"); | |
| 346 | expect(page).toContain("Ban sender"); | |
| 347 | // a stranger reads no list and the post is refused | |
| 348 | const stranger = await (await forge("/alice/notes", {}, "")).text(); | |
| 349 | expect(stranger).not.toContain("Ban sender"); | |
| 350 | expect(stranger).not.toContain('id="app-notes"'); | |
| 351 | const refused = await form({ id: sent.note.id, action: "hide" }, ""); | |
| 352 | expect(refused.status).toBe(303); | |
| 353 | expect(refused.headers.get("location") ?? "").toContain("/signin"); | |
| 354 | expect(getIssue(app.db, repo.id, number)!.state).toBe("open"); | |
| 355 | // same-origin is checked | |
| 356 | const cross = await forge("/alice/notes/app/notes", { method: "POST", headers: { origin: "http://evil.example" }, body: new URLSearchParams({ id: sent.note.id, action: "hide" }) }); | |
| 357 | expect(cross.status).toBe(403); | |
| 358 | expect((await form({ id: sent.note.id, action: "nuke" })).status).toBe(400); | |
| 359 | ||
| 360 | // hide: the issue closes and the row goes | |
| 361 | expect((await form({ id: sent.note.id, action: "hide" })).status).toBe(303); | |
| 362 | expect(getIssue(app.db, repo.id, number)!.state).toBe("closed"); | |
| 363 | expect(await (await forge("/alice/notes")).text()).not.toContain(`value="${sent.note.id}"`); | |
| 364 | ||
| 365 | // ban: the sender's next note is refused and they are listed once with Unban | |
| 366 | const second = await post("studio words two", cookie, "10.7.0.1"); | |
| 367 | expect(second.status).toBe(201); | |
| 368 | const secondId = ((await second.json()) as { note: { id: string } }).note.id; | |
| 369 | expect((await form({ id: secondId, action: "ban" })).status).toBe(303); | |
| 370 | expect((await post("again", cookie, "10.7.0.1")).status).toBe(403); | |
| 371 | const banned = await (await forge("/alice/notes")).text(); | |
| 372 | expect(banned).toContain("Banned senders"); | |
| 373 | expect(banned.split(`value="${secondId}"`).length - 1).toBe(1); | |
| 374 | // a signed-in reader who is not an admin sees no card and can do nothing | |
| 375 | const bobMade = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 376 | if (!bobMade.ok) throw new Error(bobMade.error); | |
| 377 | const bobIn = await fetch(`${servers.url}/signin`, { | |
| 378 | method: "POST", | |
| 379 | headers: { origin: servers.url }, | |
| 380 | body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }), | |
| 381 | redirect: "manual", | |
| 382 | }); | |
| 383 | const bob = (bobIn.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 384 | expect(bob).not.toBe(""); | |
| 385 | const record = getAppBySlug(app.db, "notes")!; | |
| 386 | const bobPage = await (await forge("/alice/notes", {}, bob)).text(); | |
| 387 | expect(bobPage).not.toContain('id="app-notes"'); | |
| 388 | expect(bobPage).not.toContain("studio words two"); | |
| 389 | expect(bobPage).not.toContain(sent.name); | |
| 390 | const bannedBefore = isBanned(app.db, record.id, getNote(app.db, record.id, secondId)!.visitor); | |
| 391 | expect(bannedBefore).toBe(true); | |
| 392 | const third = await post("studio words three", "", "10.7.0.9"); | |
| 393 | const thirdNote = ((await third.json()) as { note: { id: string; url: string } }).note; | |
| 394 | const thirdId = thirdNote.id; | |
| 395 | const thirdNumber = Number(thirdNote.url.split("/").pop()); | |
| 396 | for (const action of ["hide", "ban", "unban"]) { | |
| 397 | for (const id of [action === "unban" ? secondId : thirdId]) { | |
| 398 | const refused = await form({ id, action }, bob); | |
| 399 | expect(refused.status).toBe(303); | |
| 400 | const flashed = await forge("/alice/notes", {}, `${bob}; ${(refused.headers.get("set-cookie") ?? "").split(";")[0]}`); | |
| 401 | expect(await flashed.text()).toContain("You need admin access to do that."); | |
| 402 | } | |
| 403 | } | |
| 404 | expect(getIssue(app.db, repo.id, thirdNumber)!.state).toBe("open"); | |
| 405 | expect(isBanned(app.db, record.id, getNote(app.db, record.id, thirdId)!.visitor)).toBe(false); | |
| 406 | expect(isBanned(app.db, record.id, getNote(app.db, record.id, secondId)!.visitor)).toBe(true); | |
| 407 | // unban | |
| 408 | expect((await form({ id: secondId, action: "unban" })).status).toBe(303); | |
| 409 | expect((await post("back again", cookie, "10.7.0.1")).status).toBe(201); | |
| 410 | expect(await (await forge("/alice/notes")).text()).not.toContain(`value="${secondId}"`); | |
| 411 | // the app host answers 404 for moderation, and for the forge's own path too | |
| 412 | for (const path of ["/__forge/note/moderate", "/app/notes"]) | |
| 413 | expect((await at("notes", path, { method: "POST", body: JSON.stringify({ id: secondId, action: "ban" }) }, "")).status).toBe(404); | |
| 414 | }); | |
| 415 | ||
| 416 | test("the clock is the real one again", () => { | |
| 417 | noteClock.now = () => Date.now(); | |
| 418 | }); | |
| 419 | ||
| 315 | 420 | test("the panel fits a phone and a desktop, and says no forge words first", () => { |
| 316 | 421 | // 390x844: at most 320px wide within the viewport, and 70% high leaves the top (camera) clear. |
| 317 | 422 | expect(toolbarJs).toContain("max-width:min(320px,calc(100vw - 64px))"); |
test/tui-notes.test.ts +16
| @@ -11,6 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { newApp } from "../src/actions/apps.ts"; |
| 12 | 12 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | import { sendNote, type Sender } from "../src/actions/app_notes.ts"; | |
| 14 | 15 | import { isBanned } from "../src/core/app_notes.ts"; |
| 15 | 16 | import { getAppBySlug } from "../src/core/apps.ts"; |
| 16 | 17 | import { getIssue, labelsFor } from "../src/core/issues.ts"; |
| @@ -106,4 +107,19 @@ describe("notes from the app, in the terminal", () => { | ||
| 106 | 107 | const record = getAppBySlug(app.db, "notes")!; |
| 107 | 108 | expect(isBanned(app.db, record.id, `u:${bob.id}`)).toBe(true); |
| 108 | 109 | }); |
| 110 | ||
| 111 | test("the admin lists the banned senders and unbans one; a reader has no such row", async () => { | |
| 112 | const record = getAppBySlug(app.db, "notes")!; | |
| 113 | expect(isBanned(app.db, record.id, `u:${bob.id}`)).toBe(true); | |
| 114 | expect(await palette(bob, notes, [":"])).not.toContain("Banned senders"); | |
| 115 | const listed = await palette(alice, notes, [":", "Banned", "\r"]); | |
| 116 | expect(listed).toContain("@bob"); | |
| 117 | expect(await palette(bob, notes, [":", "Banned", "\r"])).not.toContain("Unban"); | |
| 118 | await palette(alice, notes, [":", "Banned", "\r", "\r", "\x1b[C", "\r"]); | |
| 119 | expect(isBanned(app.db, record.id, `u:${bob.id}`)).toBe(false); | |
| 120 | const sender: Sender = { key: `u:${bob.id}`, name: "@bob", person: true }; | |
| 121 | const again = await sendNote(app, record, notes, "production", undefined, sender, { text: "back again", page: "", viewport: "unknown" }, "ssh:again"); | |
| 122 | expect(again.ok).toBe(true); | |
| 123 | expect(await palette(alice, notes, [":", "Banned", "\r"])).not.toContain("Unban this sender"); | |
| 124 | }); | |
| 109 | 125 | }); |
This says the terminal screen is one of the owner's tools, but the terminal can't unban or list banned senders yet. Add those there, then say so in the terminal paragraph above ("may hide a note or ban its sender").