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

Merged opened this

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

Merged

scout/forge:scout/5977-explore-takes-owner-name-in-its-one-line → main 2 commits fork

  • Merged. scout/5977-explore-takes-owner-name-in-its-one-line was merged into main as 1c68922.

Review

Sign in to review this change.
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,
463463 hours in use, how lately it shipped) with the numbers on each row, and — for
464464 a site admin — the forges this one spawned. One line narrows it and stays in
465465 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),
466468 and words to match. An agent's working fork is how the crew builds, not a
467469 find, and is left out; a private app's hours are told only to those who may
468470 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 {
2525 frontWeights,
2626 type FrontSignals,
2727 } from "../core/front.ts";
28 import { isSafeName } from "../git/guard.ts";
2829 import { canEnterApp, listReposVisibleTo, type Repo } from "../core/repos.ts";
2930 import { getUsersByIds, isActive, type User } from "../core/users.ts";
3031 import { writeStamp } from "../core/write_clock.ts";
@@ -47,6 +48,8 @@ export interface ExploreQuery {
4748 readonly text: string;
4849 /** `archived:yes` — put-away repositories are in the list; off, they are not. */
4950 readonly archived: boolean;
51 /** `owner:<name>` — only this account's apps, and the person themself. */
52 readonly owner?: string;
5053 }
5154
5255 const KINDS: Record<string, ExploreKind> = {
@@ -79,6 +82,7 @@ export function parseExploreQuery(line: string): ExploreQuery {
7982 let kind: ExploreKind | "all" = "all";
8083 let sort: ExploreSort = "score";
8184 let archived = false;
85 let owner: string | undefined;
8286 const words: string[] = [];
8387 for (const word of line
8488 .trim()
@@ -94,10 +98,17 @@ export function parseExploreQuery(line: string): ExploreQuery {
9498 sort = value.toLowerCase() as ExploreSort;
9599 else if (k === "archived" && ["yes", "no"].includes(value.toLowerCase()))
96100 archived = value.toLowerCase() === "yes";
101 else if (k === "owner" && isSafeName(value)) owner = value;
97102 else if (k === "q" && value !== "") words.push(value);
98103 else words.push(word);
99104 }
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 };
101112 }
102113
103114 /** The line a query reads as, for links and the query box. */
@@ -106,6 +117,7 @@ export function exploreLine(query: ExploreQuery): string {
106117 query.kind === "all" ? "" : `kind:${query.kind}`,
107118 query.sort === "score" ? "" : `sort:${query.sort}`,
108119 query.archived ? "archived:yes" : "",
120 query.owner === undefined ? "" : `owner:${query.owner}`,
109121 query.text,
110122 ]
111123 .filter((p) => p !== "")
@@ -172,7 +184,7 @@ export async function explore(
172184
173185 /** The forges this forge spawned, for a site admin asking for them. */
174186 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 [];
176188 return (await listChildren(app)).map((child) => ({
177189 kind: "forge",
178190 name: child.name,
@@ -292,6 +304,7 @@ function rank(
292304 });
293305
294306 const text = query.text.toLowerCase();
307 const only = query.owner?.toLowerCase();
295308 // Kept rows are shared by every request that reads them: none may change.
296309 const freeze = (row: ExploreRow): ExploreRow => {
297310 Object.freeze(row.numbers);
@@ -301,6 +314,7 @@ function rank(
301314 [...appRows, ...people.values()].map(freeze).filter(
302315 (row) =>
303316 (query.kind === "all" || row.kind === query.kind) &&
317 (only === undefined || (row.repo?.owner ?? row.name).toLowerCase() === only) &&
304318 (text === "" || `${row.name} ${row.description}`.toLowerCase().includes(text)),
305319 ),
306320 query,
src/web/studio/explore.ts +17 −7
@@ -26,24 +26,34 @@ const href = (query: ExploreQuery): string => {
2626 };
2727
2828 /** 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 } {
3030 const n = row.numbers;
31 const parts = [
32 row.kind === "person" ? `${n.apps} app${n.apps === 1 ? "" : "s"}` : "",
31 const rest = [
3332 n.forks > 0 ? `${n.forks} fork${n.forks === 1 ? "" : "s"}` : "",
3433 n.ships > 0 ? `${n.ships} shipped this month` : "",
3534 n.awakeHours > 0 ? `${n.awakeHours} h in use` : "",
3635 row.shippedAt !== null ? `last shipped ${timeAgo(row.shippedAt)}` : "",
3736 ];
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 };
3941 }
4042
4143 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``);
4454 return rowOf({
4555 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 }),
4757 ...(row.copyable === true && row.repo !== undefined
4858 ? {
4959 trail: btn("Make it yours", {
test/explore.test.ts +27
@@ -54,6 +54,18 @@ describe("the one line", () => {
5454 });
5555 });
5656
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
5769 describe("explore", () => {
5870 let dataDir: string;
5971 let app: App;
@@ -237,6 +249,21 @@ describe("explore", () => {
237249 expect(names(told.items)).toEqual(names((await api("kind:app sort:ships", bobToken)).items));
238250 });
239251
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("&lt;script&gt;alert(1)");
265 });
266
240267 test("the page draws §5's row and filters, and no explore- class", async () => {
241268 const page = await (await fetch(`${base}/explore`)).text();
242269 expect(page).toContain('class="row"');

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs