Public: anyone can read this; the crew can change it.
/explore takes `owner:<name>` in its one line, and each person row links "their apps" to it #5993
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5977-explore-takes-owner-name-in-its-one-line → main 2 commits fork
4 files
changed +62
−9
docs/STUDIO.md +2
| @@ -463,6 +463,8 @@ page's signals (`core/front.ts`: forks by people, pulls merged this month, | ||
| 463 | 463 | hours in use, how lately it shipped) with the numbers on each row, and — for |
| 464 | 464 | a site admin — the forges this one spawned. One line narrows it and stays in |
| 465 | 465 | the URL: `kind:app|person|forge`, `sort:score|forks|ships|use|fresh|name`, |
| 466 | `owner:<username>` (one account's apps and the person; each person row's | |
| 467 | count of apps links there), `archived:yes` (put-away repositories too), | |
| 466 | 468 | and words to match. An agent's working fork is how the crew builds, not a |
| 467 | 469 | find, and is left out; a private app's hours are told only to those who may |
| 468 | 470 | enter it. Every app row offers **Make it yours** (a fork) to someone signed |
src/actions/explore.ts +16 −2
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | frontWeights, |
| 26 | 26 | type FrontSignals, |
| 27 | 27 | } from "../core/front.ts"; |
| 28 | import { isSafeName } from "../git/guard.ts"; | |
| 28 | 29 | import { canEnterApp, listReposVisibleTo, type Repo } from "../core/repos.ts"; |
| 29 | 30 | import { getUsersByIds, isActive, type User } from "../core/users.ts"; |
| 30 | 31 | import { writeStamp } from "../core/write_clock.ts"; |
| @@ -47,6 +48,8 @@ export interface ExploreQuery { | ||
| 47 | 48 | readonly text: string; |
| 48 | 49 | /** `archived:yes` — put-away repositories are in the list; off, they are not. */ |
| 49 | 50 | readonly archived: boolean; |
| 51 | /** `owner:<name>` — only this account's apps, and the person themself. */ | |
| 52 | readonly owner?: string; | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | const KINDS: Record<string, ExploreKind> = { |
| @@ -79,6 +82,7 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 79 | 82 | let kind: ExploreKind | "all" = "all"; |
| 80 | 83 | let sort: ExploreSort = "score"; |
| 81 | 84 | let archived = false; |
| 85 | let owner: string | undefined; | |
| 82 | 86 | const words: string[] = []; |
| 83 | 87 | for (const word of line |
| 84 | 88 | .trim() |
| @@ -94,10 +98,17 @@ export function parseExploreQuery(line: string): ExploreQuery { | ||
| 94 | 98 | sort = value.toLowerCase() as ExploreSort; |
| 95 | 99 | else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase())) |
| 96 | 100 | archived = value.toLowerCase() === "yes"; |
| 101 | else if (k === "owner" && isSafeName(value)) owner = value; | |
| 97 | 102 | else if (k === "q" && value !== "") words.push(value); |
| 98 | 103 | else words.push(word); |
| 99 | 104 | } |
| 100 | return { kind, sort, text: words.join(" ").slice(0, 100), archived }; | |
| 105 | return { | |
| 106 | kind, | |
| 107 | sort, | |
| 108 | text: words.join(" ").slice(0, 100), | |
| 109 | archived, | |
| 110 | ...(owner === undefined ? {} : { owner }), | |
| 111 | }; | |
| 101 | 112 | } |
| 102 | 113 | |
| 103 | 114 | /** The line a query reads as, for links and the query box. */ |
| @@ -106,6 +117,7 @@ export function exploreLine(query: ExploreQuery): string { | ||
| 106 | 117 | query.kind === "all" ? "" : `kind:${query.kind}`, |
| 107 | 118 | query.sort === "score" ? "" : `sort:${query.sort}`, |
| 108 | 119 | query.archived ? "archived:yes" : "", |
| 120 | query.owner === undefined ? "" : `owner:${query.owner}`, | |
| 109 | 121 | query.text, |
| 110 | 122 | ] |
| 111 | 123 | .filter((p) => p !== "") |
| @@ -172,7 +184,7 @@ export async function explore( | ||
| 172 | 184 | |
| 173 | 185 | /** The forges this forge spawned, for a site admin asking for them. */ |
| 174 | 186 | async function forges(app: App, viewer: User | undefined, query: ExploreQuery): Promise<ExploreRow[]> { |
| 175 | if (viewer?.isAdmin !== true || (query.kind !== "all" && query.kind !== "forge")) return []; | |
| 187 | if (query.owner !== undefined || viewer?.isAdmin !== true || (query.kind !== "all" && query.kind !== "forge")) return []; | |
| 176 | 188 | return (await listChildren(app)).map((child) => ({ |
| 177 | 189 | kind: "forge", |
| 178 | 190 | name: child.name, |
| @@ -292,6 +304,7 @@ function rank( | ||
| 292 | 304 | }); |
| 293 | 305 | |
| 294 | 306 | const text = query.text.toLowerCase(); |
| 307 | const only = query.owner?.toLowerCase(); | |
| 295 | 308 | // Kept rows are shared by every request that reads them: none may change. |
| 296 | 309 | const freeze = (row: ExploreRow): ExploreRow => { |
| 297 | 310 | Object.freeze(row.numbers); |
| @@ -301,6 +314,7 @@ function rank( | ||
| 301 | 314 | [...appRows, ...people.values()].map(freeze).filter( |
| 302 | 315 | (row) => |
| 303 | 316 | (query.kind === "all" || row.kind === query.kind) && |
| 317 | (only === undefined || (row.repo?.owner ?? row.name).toLowerCase() === only) && | |
| 304 | 318 | (text === "" || `${row.name} ${row.description}`.toLowerCase().includes(text)), |
| 305 | 319 | ), |
| 306 | 320 | query, |
src/web/studio/explore.ts +17 −7
| @@ -26,24 +26,34 @@ const href = (query: ExploreQuery): string => { | ||
| 26 | 26 | }; |
| 27 | 27 | |
| 28 | 28 | /** A row's numbers as a person reads them; nothing that is zero. */ |
| 29 | function numbers(row: ExploreRow): string { | |
| 29 | function numbers(row: ExploreRow): { apps: string; rest: string } { | |
| 30 | 30 | const n = row.numbers; |
| 31 | const parts = [ | |
| 32 | row.kind === "person" ? `${n.apps} app${n.apps === 1 ? "" : "s"}` : "", | |
| 31 | const rest = [ | |
| 33 | 32 | n.forks > 0 ? `${n.forks} fork${n.forks === 1 ? "" : "s"}` : "", |
| 34 | 33 | n.ships > 0 ? `${n.ships} shipped this month` : "", |
| 35 | 34 | n.awakeHours > 0 ? `${n.awakeHours} h in use` : "", |
| 36 | 35 | row.shippedAt !== null ? `last shipped ${timeAgo(row.shippedAt)}` : "", |
| 37 | 36 | ]; |
| 38 | return parts.filter((p) => p !== "").join(" · "); | |
| 37 | return { | |
| 38 | apps: row.kind === "person" ? `${n.apps} app${n.apps === 1 ? "" : "s"}` : "", | |
| 39 | rest: rest.filter((p) => p !== "").join(" · "), | |
| 40 | }; | |
| 39 | 41 | } |
| 40 | 42 | |
| 41 | 43 | function rowHtml(row: ExploreRow): Html { |
| 42 | const said = numbers(row); | |
| 43 | const meta = [row.description, said].filter((p) => p !== "" && p !== undefined); | |
| 44 | const { apps, rest } = numbers(row); | |
| 45 | // One tap from a person to their apps; with none, the count is plain text. | |
| 46 | const theirs = href({ kind: "app", sort: "score", text: "", archived: false, owner: row.name }); | |
| 47 | const count = apps === "" ? html`` : row.numbers.apps > 0 ? html`<a href="${theirs}">${apps}</a>` : html`${apps}`; | |
| 48 | const parts = [ | |
| 49 | row.description === "" ? html`` : html`${row.description}`, | |
| 50 | count, | |
| 51 | rest === "" ? html`` : html`${rest}`, | |
| 52 | ].filter((p) => p.toString() !== ""); | |
| 53 | const metaHtml = parts.reduce((all, p, i) => (i === 0 ? p : html`${all} · ${p}`), html``); | |
| 44 | 54 | return rowOf({ |
| 45 | 55 | title: html`<a href="${row.url}">${row.name}</a>${nameMark(row.mark)}`, |
| 46 | ...(meta.length === 0 ? {} : { meta: html`${meta.join(" · ")}` }), | |
| 56 | ...(parts.length === 0 ? {} : { meta: metaHtml }), | |
| 47 | 57 | ...(row.copyable === true && row.repo !== undefined |
| 48 | 58 | ? { |
| 49 | 59 | trail: btn("Make it yours", { |
test/explore.test.ts +27
| @@ -54,6 +54,18 @@ describe("the one line", () => { | ||
| 54 | 54 | }); |
| 55 | 55 | }); |
| 56 | 56 | |
| 57 | describe("owner:", () => { | |
| 58 | test("is parsed as a username, written back, and ignored when hostile", () => { | |
| 59 | const q = parseExploreQuery("kind:app owner:alice garden"); | |
| 60 | expect(q.owner).toBe("alice"); | |
| 61 | expect(q.text).toBe("garden"); | |
| 62 | expect(exploreLine(q)).toBe("kind:app owner:alice garden"); | |
| 63 | for (const bad of ["owner:", "owner:../x", "owner:<script>", "owner:a/b"]) { | |
| 64 | expect(parseExploreQuery(bad).owner).toBeUndefined(); | |
| 65 | } | |
| 66 | }); | |
| 67 | }); | |
| 68 | ||
| 57 | 69 | describe("explore", () => { |
| 58 | 70 | let dataDir: string; |
| 59 | 71 | let app: App; |
| @@ -237,6 +249,21 @@ describe("explore", () => { | ||
| 237 | 249 | expect(names(told.items)).toEqual(names((await api("kind:app sort:ships", bobToken)).items)); |
| 238 | 250 | }); |
| 239 | 251 | |
| 252 | test("owner: keeps one account's apps; a person row links its apps there", async () => { | |
| 253 | expect(names((await api("kind:app owner:alice")).items).sort()).toEqual(["alice/gated", "alice/loved", "alice/quiet"]); | |
| 254 | expect(names((await api("kind:app owner:bob")).items)).toEqual(["bob/busy"]); | |
| 255 | expect(names((await api("owner:bob")).items).sort()).toEqual(["bob", "bob/busy"]); | |
| 256 | expect((await api("kind:app owner:..")).items).toEqual([]); | |
| 257 | const page = await (await fetch(`${base}/explore?q=kind:person`)).text(); | |
| 258 | expect(page).toContain(`href="/explore?q=${encodeURIComponent("kind:app owner:bob")}">1 app</a>`); | |
| 259 | }); | |
| 260 | ||
| 261 | test("a hostile owner stays escaped text on the page", async () => { | |
| 262 | const page = await (await fetch(`${base}/explore?q=${encodeURIComponent("owner:<script>alert(1)</script>")}`)).text(); | |
| 263 | expect(page).not.toContain("<script>alert(1)"); | |
| 264 | expect(page).toContain("<script>alert(1)"); | |
| 265 | }); | |
| 266 | ||
| 240 | 267 | test("the page draws §5's row and filters, and no explore- class", async () => { |
| 241 | 268 | const page = await (await fetch(`${base}/explore`)).text(); |
| 242 | 269 | expect(page).toContain('class="row"'); |
new above
offline