Public: anyone can read this; the crew can change it.
The /feed composer is a plain textarea and its Post bar, not a boxed card #10019
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10016-the-feed-composer-is-a-plain-textarea-an → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +95
−5
src/web/studio/feed.ts +1 −3
| @@ -258,8 +258,7 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 258 | 258 | action: html`${btn("New app", { href: "/apps/new", variant: "primary" })} |
| 259 | 259 | ${btn("Repositories", { href: `/${user.username}` })}`, |
| 260 | 260 | })}`; |
| 261 | const form = html`<form id="composer" class="card" method="post" action="/tell"> | |
| 262 | <div class="card-body stack"> | |
| 261 | const form = html`<form id="composer" class="stack" method="post" action="/tell"> | |
| 263 | 262 | <input type="hidden" name="back" value="/feed" /> |
| 264 | 263 | <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea> |
| 265 | 264 | <div class="bar"> |
| @@ -272,7 +271,6 @@ export function composer(ctx: Ctx, user: User): Html { | ||
| 272 | 271 | <span class="spacer"></span> |
| 273 | 272 | ${btn("Post", { variant: "primary" })} |
| 274 | 273 | </div> |
| 275 | </div> | |
| 276 | 274 | </form>`; |
| 277 | 275 | return repos.length === 0 ? html`${form}${noApps}` : form; |
| 278 | 276 | } |
test/feed-composer-language.test.ts added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | /** The /feed composer speaks the home page's language (#10016): one field and one bar, 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, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { promote } from "./helpers/members.ts"; | |
| 13 | ||
| 14 | let dir = ""; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let cookie = ""; | |
| 18 | let bare = ""; | |
| 19 | ||
| 20 | async function user(username: string, first = false) { | |
| 21 | const r = await createUser(app.db, { | |
| 22 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 23 | password: "a-good-passphrase", adminIfFirst: first, | |
| 24 | }); | |
| 25 | if (!r.ok) throw new Error(r.error); | |
| 26 | promote(app.db, r.value); | |
| 27 | return r.value; | |
| 28 | } | |
| 29 | ||
| 30 | beforeAll(async () => { | |
| 31 | dir = await mkdtemp(join(tmpdir(), "forge-feed-composer-")); | |
| 32 | app = createApp({ dataDir: dir, port: 0 }); | |
| 33 | servers = await serve(app); | |
| 34 | await user("keeper", true); | |
| 35 | const alice = await user("alice"); | |
| 36 | const carol = await user("carol"); | |
| 37 | const repo = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 38 | if (!repo.ok) throw new Error("repo"); | |
| 39 | insertApp(app.db, { repoId: repo.value.id, slug: "garden", access: "public" }); | |
| 40 | cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=studio`; | |
| 41 | bare = `forge_session=${createSession(app.db, carol.id).token}; forge_mode=studio`; | |
| 42 | }); | |
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | app.close(); | |
| 46 | await rm(dir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | const composerOf = async (c: string): Promise<string> => { | |
| 50 | const text = await (await fetch(`${servers.url}/feed`, { headers: { cookie: c } })).text(); | |
| 51 | const at = text.indexOf('<form id="composer"'); | |
| 52 | expect(at).toBeGreaterThan(-1); | |
| 53 | return text.slice(at, text.indexOf("</form>", at)); | |
| 54 | }; | |
| 55 | ||
| 56 | test("the composer is one field and one bar, with no card", async () => { | |
| 57 | const form = await composerOf(cookie); | |
| 58 | const open = form.slice(0, form.indexOf(">") + 1); | |
| 59 | expect(open).not.toContain("card"); | |
| 60 | expect(form).not.toContain("card-body"); | |
| 61 | expect(open).toContain('action="/tell"'); | |
| 62 | expect(form).toContain('name="back" value="/feed"'); | |
| 63 | expect(form).toContain("data-draft="); | |
| 64 | expect(form).toContain('<div class="bar">'); | |
| 65 | expect(form).toMatch(/>\s*Post\s*</); | |
| 66 | }); | |
| 67 | ||
| 68 | test("the select lists the viewer's repositories, escaped, and the forge", async () => { | |
| 69 | const form = await composerOf(cookie); | |
| 70 | expect(form).toContain('<option value="alice/garden">alice/garden</option>'); | |
| 71 | expect(form).toContain('<option value="forge">the forge</option>'); | |
| 72 | }); | |
| 73 | ||
outdated
This does not exercise escaping: |
||
| 74 | test("a repository name with markup in it is escaped in the select, never raw", async () => { | |
| 75 | // Names are validated at creation, so the hostile one is written straight to the row: | |
| 76 | // the page must still escape what it prints. | |
| 77 | const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!; | |
| 78 | const made = await createRepo(app, { id: alice.id } as never, { name: "wild", visibility: "public" }); | |
| 79 | if (!made.ok) throw new Error("repo"); | |
| 80 | insertApp(app.db, { repoId: made.value.id, slug: "wild", access: "public" }); | |
| 81 | app.db.query("UPDATE repo SET name = ?1 WHERE id = ?2").run('x"><img src=x onerror=alert(1)>', made.value.id); | |
| 82 | const form = await composerOf(cookie); | |
| 83 | expect(form).not.toContain("<img"); | |
| 84 | expect(form).not.toContain('x">'); | |
| 85 | expect(form).toContain("alice/x"><img src=x onerror=alert(1)>"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("with no repositories, the forge is the hidden target", async () => { | |
| 89 | const form = await composerOf(bare); | |
| 90 | expect(form).not.toContain("<select"); | |
| 91 | expect(form).toContain('name="app" value="forge"'); | |
| 92 | }); |
test/home-feed.test.ts +2 −2
| @@ -124,9 +124,9 @@ describe("home is the feed", () => { | ||
| 124 | 124 | await rm(dataDir, { recursive: true, force: true }); |
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | test("the composer is a §5 card, and no .composer class remains", async () => { | |
| 127 | test("the composer is a plain form (no card), and no .composer class remains", async () => { | |
| 128 | 128 | const text = await (await get("/feed", aliceCookie)).text(); |
| 129 | expect(text).toMatch(/<form id="composer" class="card" method="post" action="\/tell">\s*<div class="card-body/); | |
| 129 | expect(text).toMatch(/<form id="composer" class="stack" method="post" action="\/tell">/); | |
| 130 | 130 | expect(text).not.toContain('class="composer'); |
| 131 | 131 | }); |
| 132 | 132 |
The inner lines (hidden field, textarea, bar) keep the old wrapper's indentation. Dedent them one level now that the wrapper is gone.