nixamp 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/session.js CHANGED
@@ -578,3 +578,89 @@ export async function servers(argv, fetcher = fetch) {
578
578
  return 69;
579
579
  }
580
580
  }
581
+ /**
582
+ * `nixamp opendir list|add|remove`, the public list of folders people found.
583
+ *
584
+ * Reading needs no account, which is why `list` works signed out. Adding needs
585
+ * one, because a public list with nobody accountable for its rows is a list of
586
+ * whatever anybody felt like putting there.
587
+ */
588
+ export async function opendirs(argv, fetcher = fetch) {
589
+ const [command = "list", ...rest] = argv;
590
+ const session = readSession();
591
+ const site = session?.site ?? DEFAULT_DIRECTORY;
592
+ const where = `${site}/api/v1/opendirs`;
593
+ const headers = {
594
+ "content-type": "application/json",
595
+ ...(session ? { authorization: `Bearer ${session.token}` } : {}),
596
+ };
597
+ const at = (flag) => {
598
+ const index = rest.indexOf(flag);
599
+ return index === -1 ? undefined : rest[index + 1];
600
+ };
601
+ try {
602
+ if (command === "list" || command === "ls") {
603
+ const answer = await fetcher(where, { headers });
604
+ const body = (await answer.json().catch(() => ({})));
605
+ if (!answer.ok) {
606
+ console.error(`nixamp: ${body.error ?? `could not read the list (${answer.status})`}`);
607
+ return 1;
608
+ }
609
+ const rows = body.opendirs ?? [];
610
+ if (rows.length === 0) {
611
+ console.log("Nothing published yet. `nixamp opendir add <url>` puts one there.");
612
+ return 0;
613
+ }
614
+ const width = Math.max(...rows.map((row) => row.name.length));
615
+ for (const row of rows) {
616
+ const who = row.by ? ` by ${row.by}` : "";
617
+ console.log(`${row.id} ${row.name.padEnd(width)} ${row.tracks} tracks${who}`);
618
+ console.log(` ${row.url}`);
619
+ }
620
+ return 0;
621
+ }
622
+ if (session === null) {
623
+ console.error("nixamp: sign in to publish or remove one. Try `nixamp login`.");
624
+ return 1;
625
+ }
626
+ if (command === "add" || command === "publish") {
627
+ const target = rest.find((a) => /^https?:\/\//.test(a)) ?? "";
628
+ if (!target) {
629
+ console.error("nixamp: which folder? Give the URL of a directory listing.");
630
+ return 64;
631
+ }
632
+ const answer = await fetcher(where, {
633
+ method: "POST",
634
+ headers,
635
+ body: JSON.stringify({ url: target, name: at("--name") ?? "" }),
636
+ });
637
+ const body = (await answer.json().catch(() => ({})));
638
+ if (!answer.ok || !body.opendir) {
639
+ console.error(`nixamp: ${body.error ?? `could not publish it (${answer.status})`}`);
640
+ return 1;
641
+ }
642
+ console.log(`${body.opendir.id} ${body.opendir.name} ${body.opendir.tracks} tracks`);
643
+ return 0;
644
+ }
645
+ if (command === "remove" || command === "rm") {
646
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
647
+ if (!id) {
648
+ console.error("nixamp: which one? `nixamp opendir list` shows their ids.");
649
+ return 64;
650
+ }
651
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
652
+ if (!answer.ok) {
653
+ console.error(`nixamp: ${answer.status === 404 ? "not yours, or not there" : "could not remove it"}`);
654
+ return 1;
655
+ }
656
+ console.log(`Removed ${id}.`);
657
+ return 0;
658
+ }
659
+ console.error(`nixamp: no such opendir command: ${command}`);
660
+ return 64;
661
+ }
662
+ catch (error) {
663
+ console.error(`nixamp: could not reach ${site}: ${error.message}`);
664
+ return 69;
665
+ }
666
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/handles.ts CHANGED
@@ -35,7 +35,9 @@ const SCHEMA = `
35
35
  export function cleanHandle(value: unknown): string {
36
36
  if (typeof value !== "string") return "";
37
37
  const wanted = value.trim().toLowerCase();
38
- if (!/^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(wanted)) return "";
38
+ // Two characters minimum, thirty maximum: the trailing character is required,
39
+ // which is also what stops a handle ending in a hyphen.
40
+ if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(wanted)) return "";
39
41
  // Doubled hyphens are how punycode marks an encoded label, so a handle with
40
42
  // one in it can collide with an internationalised domain.
41
43
  return wanted.includes("--") ? "" : wanted;
@@ -85,6 +87,25 @@ export class Handles {
85
87
  return rows[0] ? String(rows[0]["handle"] ?? "") : "";
86
88
  }
87
89
 
90
+ /**
91
+ * Handles for a page of rows, in one query.
92
+ *
93
+ * A public listing names people, and naming them one query at a time is how a
94
+ * list of fifty becomes fifty round trips. Anyone without a handle is absent
95
+ * from the map rather than present as an empty string, so a caller decides
96
+ * what to show for somebody who never picked one.
97
+ */
98
+ async many(userIds: string[]): Promise<Map<string, string>> {
99
+ const wanted = [...new Set(userIds.filter(Boolean))];
100
+ if (wanted.length === 0) return new Map();
101
+ await this.ensure();
102
+ const { rows } = await this.db.query(
103
+ `SELECT user_id, handle FROM ${TABLE} WHERE user_id = ANY($1)`,
104
+ [wanted],
105
+ );
106
+ return new Map(rows.map((row) => [String(row["user_id"] ?? ""), String(row["handle"] ?? "")]));
107
+ }
108
+
88
109
  /** Who holds this handle, so a listing can name somebody without their address. */
89
110
  async holder(handle: string): Promise<string> {
90
111
  const wanted = cleanHandle(handle);
package/src/main.ts CHANGED
@@ -76,6 +76,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
76
76
  nixamp logout / whoami forget it, or check it
77
77
  nixamp token create|list|revoke tokens for a machine that cannot sign in
78
78
  nixamp server list|add|remove the machines you run, kept against your account
79
+ nixamp opendir list|add|remove folders found on the web, published for everyone
79
80
  nixamp update [version] re-run the installer, keeping your choices
80
81
  nixamp uninstall [--yes] remove everything the installer created
81
82
 
@@ -326,6 +327,11 @@ export async function main(): Promise<void> {
326
327
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
327
328
  return;
328
329
  }
330
+ if (first === "opendir" || first === "opendirs") {
331
+ const { opendirs } = await import("./session.ts");
332
+ process.exitCode = await opendirs(rest);
333
+ return;
334
+ }
329
335
  if (first === "server" || first === "servers") {
330
336
  const { servers } = await import("./session.ts");
331
337
  process.exitCode = await servers(rest);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Open directories somebody found, kept as a list anyone can read.
3
+ *
4
+ * Deliberately not the stream directory. That one lists what is playing right
5
+ * now: a heartbeat, a TTL, a room code, somebody at the other end. An open
6
+ * directory is the opposite in every one of those respects. It is always there,
7
+ * nobody is broadcasting it, and it is not the finder's to broadcast. Mixing
8
+ * the two would put "chovy is playing this, dial in" next to "here is a link
9
+ * to a stranger's file server" under one heading.
10
+ *
11
+ * Public to read and signed in to add, because a public list with nobody
12
+ * accountable for its rows is a public list of whatever anybody felt like
13
+ * putting there. What is shown against a row is the finder's handle, never the
14
+ * address they signed up with.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ import type { Queryable } from "./follows.ts";
18
+ import { cleanUrl } from "./servers.ts";
19
+
20
+ const TABLE = "nixamp_opendirs";
21
+
22
+ const SCHEMA = `
23
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
24
+ id TEXT PRIMARY KEY,
25
+ url TEXT NOT NULL,
26
+ name TEXT NOT NULL DEFAULT '',
27
+ added_by TEXT NOT NULL,
28
+ tracks INTEGER NOT NULL DEFAULT 0,
29
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
30
+ );
31
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_url ON ${TABLE} (url);
32
+ CREATE INDEX IF NOT EXISTS ${TABLE}_added ON ${TABLE} (created_at DESC);
33
+ `;
34
+
35
+ export interface OpenDir {
36
+ id: string;
37
+ url: string;
38
+ name: string;
39
+ /** How many playable files were on the page when it was added. */
40
+ tracks: number;
41
+ /** The finder's public handle. Never their address. */
42
+ by: string;
43
+ createdAt: number;
44
+ }
45
+
46
+ /**
47
+ * The name to show for a folder nobody named.
48
+ *
49
+ * The last path segment, which for a folder of music is the album, written the
50
+ * way a person wrote it rather than the way a URL spells it.
51
+ */
52
+ export function nameOfDir(url: string): string {
53
+ try {
54
+ const parts = new URL(url).pathname.split("/").filter(Boolean);
55
+ const last = parts[parts.length - 1];
56
+ return last ? decodeURIComponent(last).slice(0, 120) : new URL(url).host;
57
+ } catch {
58
+ return "an open directory";
59
+ }
60
+ }
61
+
62
+ function asTime(value: unknown): number {
63
+ if (value instanceof Date) return value.getTime();
64
+ if (typeof value === "string") {
65
+ const at = Date.parse(value);
66
+ return Number.isNaN(at) ? 0 : at;
67
+ }
68
+ return typeof value === "number" ? value : 0;
69
+ }
70
+
71
+ export class OpenDirs {
72
+ private ready: Promise<void> | null = null;
73
+
74
+ constructor(private readonly db: Queryable) {}
75
+
76
+ private async ensure(): Promise<void> {
77
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
78
+ await this.ready;
79
+ }
80
+
81
+ /**
82
+ * The list, newest first, in pages.
83
+ *
84
+ * Keyset on the row's own creation time rather than an offset, because a list
85
+ * anybody may add to shifts under a reader who is paging through it, and an
86
+ * offset in a shifting list repeats and skips rows.
87
+ */
88
+ async list(limit = 50, before = 0): Promise<{ rows: Omit<OpenDir, "by">[]; addedBy: string[]; next: number }> {
89
+ await this.ensure();
90
+ const size = Math.min(200, Math.max(1, limit));
91
+ const { rows } = await this.db.query(
92
+ `SELECT id, url, name, added_by, tracks, created_at FROM ${TABLE}
93
+ ${before > 0 ? "WHERE created_at < $2" : ""}
94
+ ORDER BY created_at DESC LIMIT $1`,
95
+ before > 0 ? [size + 1, new Date(before).toISOString()] : [size + 1],
96
+ );
97
+
98
+ // One more than asked for, so "is there another page" is a fact rather
99
+ // than a guess from a full one.
100
+ const page = rows.slice(0, size);
101
+ return {
102
+ rows: page.map((row) => ({
103
+ id: String(row["id"] ?? ""),
104
+ url: String(row["url"] ?? ""),
105
+ name: String(row["name"] ?? ""),
106
+ tracks: Number(row["tracks"] ?? 0),
107
+ createdAt: asTime(row["created_at"]),
108
+ })),
109
+ addedBy: page.map((row) => String(row["added_by"] ?? "")),
110
+ next: rows.length > size ? asTime(page[page.length - 1]?.["created_at"]) : 0,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Publish one. `tracks` is what the finder's server actually saw on the page,
116
+ * so a row nobody can play never reaches the list.
117
+ */
118
+ async add(userId: string, url: unknown, name: unknown, tracks: number): Promise<Omit<OpenDir, "by"> | null> {
119
+ const clean = typeof url === "string" ? url.trim() : "";
120
+ // Not cleanUrl's origin-only treatment: a folder is a path, and the path is
121
+ // the whole point of the row.
122
+ if (!/^https?:\/\//i.test(clean) || cleanUrl(clean) === "") return null;
123
+ if (tracks <= 0) return null;
124
+
125
+ await this.ensure();
126
+ const { rows } = await this.db.query(
127
+ `INSERT INTO ${TABLE} (id, url, name, added_by, tracks) VALUES ($1, $2, $3, $4, $5)
128
+ ON CONFLICT (url) DO UPDATE SET name = EXCLUDED.name, tracks = EXCLUDED.tracks
129
+ RETURNING id, url, name, tracks, created_at`,
130
+ [
131
+ randomBytes(8).toString("hex"),
132
+ clean,
133
+ (typeof name === "string" && name.trim() ? name.trim() : nameOfDir(clean)).slice(0, 120),
134
+ userId,
135
+ Math.min(100_000, tracks),
136
+ ],
137
+ );
138
+ const row = rows[0];
139
+ if (!row) return null;
140
+ return {
141
+ id: String(row["id"] ?? ""),
142
+ url: String(row["url"] ?? ""),
143
+ name: String(row["name"] ?? ""),
144
+ tracks: Number(row["tracks"] ?? 0),
145
+ createdAt: asTime(row["created_at"]),
146
+ };
147
+ }
148
+
149
+ /** Only the finder takes their own row down. */
150
+ async remove(userId: string, id: string): Promise<boolean> {
151
+ await this.ensure();
152
+ const { rows } = await this.db.query(
153
+ `DELETE FROM ${TABLE} WHERE id = $1 AND added_by = $2 RETURNING id`,
154
+ [id, userId],
155
+ );
156
+ return rows.length > 0;
157
+ }
158
+ }
package/src/server.ts CHANGED
@@ -30,6 +30,7 @@ import { Channels, cleanId } from "./channels.ts";
30
30
  import { RtmpListeners } from "./rtmp-in.ts";
31
31
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
32
32
  import { anonymousHandle, Handles } from "./handles.ts";
33
+ import { OpenDirs } from "./opendirs.ts";
33
34
  import { Servers } from "./servers.ts";
34
35
  import { DeviceGrants } from "./device.ts";
35
36
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
@@ -82,7 +83,7 @@ import {
82
83
  type Tools, type Track,
83
84
  } from "./audio.ts";
84
85
  import { Analyser, bandEdges, bands, decay } from "./fft.ts";
85
- import { loadSource, loadTagged } from "./playlist.ts";
86
+ import { loadSource, loadTagged, readRemoteIndex } from "./playlist.ts";
86
87
  import {
87
88
  emptySnapshot, parseCommand,
88
89
  type Command, type RemoteTrack, type Snapshot,
@@ -695,6 +696,9 @@ export function isSignInPath(path: string): boolean {
695
696
  path === "/api/v1/servers" ||
696
697
  path.startsWith("/api/v1/servers/") ||
697
698
  path === "/api/v1/me/handle" ||
699
+ // Public to read, so it must not be behind a share key either.
700
+ path === "/api/v1/opendirs" ||
701
+ path.startsWith("/api/v1/opendirs/") ||
698
702
  OAUTH_ROUTE.test(path)
699
703
  );
700
704
  }
@@ -783,6 +787,8 @@ export interface HandlerOptions {
783
787
  servers?: Servers;
784
788
  /** The name other people see, which is never the address they signed up with. */
785
789
  handles?: Handles;
790
+ /** Open directories people have found, which anyone may read. */
791
+ openDirs?: OpenDirs;
786
792
  /** True when this instance is reached over https, for the cookie's Secure. */
787
793
  secureCookies?: boolean;
788
794
  /** A certificate and key in PEM, when this server is to speak https itself. */
@@ -1261,114 +1267,6 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1261
1267
  return;
1262
1268
  }
1263
1269
 
1264
- // --- the name other people see ---------------------------------------
1265
- //
1266
- // Separate from the address on purpose. The address is a credential and
1267
- // a way to reach somebody; publishing it in a directory listing or an
1268
- // invite would be publishing what they log in with.
1269
- if (path === "/api/v1/me/handle" && options.handles) {
1270
- const handles = options.handles;
1271
- const who = await accounts.whoIs(tokenFrom(request.headers));
1272
- if (who === null) {
1273
- json(response, 401, { error: "not signed in" });
1274
- return;
1275
- }
1276
-
1277
- if (request.method === "GET") {
1278
- json(response, 200, { handle: await handles.of(who.id) });
1279
- return;
1280
- }
1281
- if (request.method === "PUT" || request.method === "POST") {
1282
- let body: { handle?: unknown };
1283
- try {
1284
- body = JSON.parse(await readBody(request)) as typeof body;
1285
- } catch {
1286
- json(response, 400, { error: "bad JSON" });
1287
- return;
1288
- }
1289
- const claimed = await handles.claim(who.id, body.handle);
1290
- if (claimed.error) {
1291
- json(response, 409, { error: claimed.error });
1292
- return;
1293
- }
1294
- json(response, 200, { handle: claimed.handle });
1295
- return;
1296
- }
1297
- json(response, 405, { error: "GET or PUT" });
1298
- return;
1299
- }
1300
-
1301
- // --- the servers this account runs ----------------------------------
1302
- //
1303
- // Kept against the account rather than the machine, so the list reads the
1304
- // same from the CLI, the PWA and the desktop app -- which is the whole
1305
- // point: a share link in a terminal you closed is a server you have lost.
1306
- if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
1307
- const servers = options.servers;
1308
- const who = await accounts.whoIs(tokenFrom(request.headers));
1309
- if (who === null) {
1310
- json(response, 401, { error: "not signed in" });
1311
- return;
1312
- }
1313
-
1314
- if (path === "/api/v1/servers" && request.method === "GET") {
1315
- json(response, 200, { servers: await servers.list(who.id) });
1316
- return;
1317
- }
1318
-
1319
- if (path === "/api/v1/servers" && request.method === "POST") {
1320
- let body: { name?: unknown; url?: unknown; key?: unknown };
1321
- try {
1322
- body = JSON.parse(await readBody(request)) as typeof body;
1323
- } catch {
1324
- json(response, 400, { error: "bad JSON" });
1325
- return;
1326
- }
1327
- const made = await servers.add(who, {
1328
- ...(typeof body.name === "string" ? { name: body.name } : {}),
1329
- ...(typeof body.url === "string" ? { url: body.url } : {}),
1330
- ...(typeof body.key === "string" ? { key: body.key } : {}),
1331
- });
1332
- if (made === null) {
1333
- json(response, 422, { error: "that needs an http or https address" });
1334
- return;
1335
- }
1336
- json(response, 201, { server: made });
1337
- return;
1338
- }
1339
-
1340
- const id = path.slice("/api/v1/servers/".length);
1341
- if (id && request.method === "DELETE") {
1342
- const gone = await servers.remove(who.id, id);
1343
- json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1344
- return;
1345
- }
1346
-
1347
- if (id && (request.method === "PATCH" || request.method === "PUT")) {
1348
- let body: { name?: unknown; url?: unknown; key?: unknown };
1349
- try {
1350
- body = JSON.parse(await readBody(request)) as typeof body;
1351
- } catch {
1352
- json(response, 400, { error: "bad JSON" });
1353
- return;
1354
- }
1355
- const changed = await servers.update(who.id, id, {
1356
- ...(typeof body.name === "string" ? { name: body.name } : {}),
1357
- ...(typeof body.url === "string" ? { url: body.url } : {}),
1358
- ...(typeof body.key === "string" ? { key: body.key } : {}),
1359
- });
1360
- if (changed === null) {
1361
- json(response, 404, { error: "no such server, or a bad address" });
1362
- return;
1363
- }
1364
- json(response, 200, { server: changed });
1365
- return;
1366
- }
1367
-
1368
- json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1369
- return;
1370
- }
1371
-
1372
1270
  // --- tokens a person made on purpose --------------------------------
1373
1271
  if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
1374
1272
  const who = await accounts.whoIs(tokenFrom(request.headers));
@@ -1495,6 +1393,187 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1495
1393
  return;
1496
1394
  }
1497
1395
 
1396
+ // --- the name other people see ---------------------------------------
1397
+ //
1398
+ // Separate from the address on purpose. The address is a credential and
1399
+ // a way to reach somebody; publishing it in a directory listing or an
1400
+ // invite would be publishing what they log in with.
1401
+ if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
1402
+ const handles = options.handles;
1403
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1404
+ if (who === null) {
1405
+ json(response, 401, { error: "not signed in" });
1406
+ return;
1407
+ }
1408
+
1409
+ if (request.method === "GET") {
1410
+ json(response, 200, { handle: await handles.of(who.id) });
1411
+ return;
1412
+ }
1413
+ if (request.method === "PUT" || request.method === "POST") {
1414
+ let body: { handle?: unknown };
1415
+ try {
1416
+ body = JSON.parse(await readBody(request)) as typeof body;
1417
+ } catch {
1418
+ json(response, 400, { error: "bad JSON" });
1419
+ return;
1420
+ }
1421
+ const claimed = await handles.claim(who.id, body.handle);
1422
+ if (claimed.error) {
1423
+ json(response, 409, { error: claimed.error });
1424
+ return;
1425
+ }
1426
+ json(response, 200, { handle: claimed.handle });
1427
+ return;
1428
+ }
1429
+ json(response, 405, { error: "GET or PUT" });
1430
+ return;
1431
+ }
1432
+
1433
+ // --- the servers this account runs ----------------------------------
1434
+ //
1435
+ // Kept against the account rather than the machine, so the list reads the
1436
+ // same from the CLI, the PWA and the desktop app -- which is the whole
1437
+ // point: a share link in a terminal you closed is a server you have lost.
1438
+ if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
1439
+ const servers = options.servers;
1440
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1441
+ if (who === null) {
1442
+ json(response, 401, { error: "not signed in" });
1443
+ return;
1444
+ }
1445
+
1446
+ if (path === "/api/v1/servers" && request.method === "GET") {
1447
+ json(response, 200, { servers: await servers.list(who.id) });
1448
+ return;
1449
+ }
1450
+
1451
+ if (path === "/api/v1/servers" && request.method === "POST") {
1452
+ let body: { name?: unknown; url?: unknown; key?: unknown };
1453
+ try {
1454
+ body = JSON.parse(await readBody(request)) as typeof body;
1455
+ } catch {
1456
+ json(response, 400, { error: "bad JSON" });
1457
+ return;
1458
+ }
1459
+ const made = await servers.add(who, {
1460
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1461
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1462
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1463
+ });
1464
+ if (made === null) {
1465
+ json(response, 422, { error: "that needs an http or https address" });
1466
+ return;
1467
+ }
1468
+ json(response, 201, { server: made });
1469
+ return;
1470
+ }
1471
+
1472
+ const id = path.slice("/api/v1/servers/".length);
1473
+ if (id && request.method === "DELETE") {
1474
+ const gone = await servers.remove(who.id, id);
1475
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1476
+ return;
1477
+ }
1478
+
1479
+ if (id && (request.method === "PATCH" || request.method === "PUT")) {
1480
+ let body: { name?: unknown; url?: unknown; key?: unknown };
1481
+ try {
1482
+ body = JSON.parse(await readBody(request)) as typeof body;
1483
+ } catch {
1484
+ json(response, 400, { error: "bad JSON" });
1485
+ return;
1486
+ }
1487
+ const changed = await servers.update(who.id, id, {
1488
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1489
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1490
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1491
+ });
1492
+ if (changed === null) {
1493
+ json(response, 404, { error: "no such server, or a bad address" });
1494
+ return;
1495
+ }
1496
+ json(response, 200, { server: changed });
1497
+ return;
1498
+ }
1499
+
1500
+ json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1501
+ return;
1502
+ }
1503
+
1504
+ // --- open directories somebody found -----------------------------------
1505
+ //
1506
+ // Its own list, not the stream directory: that one is what is playing now,
1507
+ // with a heartbeat and a room code and somebody at the other end, and this
1508
+ // one is a folder on the web that is always there and belongs to nobody
1509
+ // here. Reading needs no account. Adding does, because a public list with
1510
+ // nobody accountable for its rows is a list of whatever anyone felt like.
1511
+ if ((path === "/api/v1/opendirs" || path.startsWith("/api/v1/opendirs/")) && options.openDirs) {
1512
+ const dirs = options.openDirs;
1513
+
1514
+ if (path === "/api/v1/opendirs" && request.method === "GET") {
1515
+ const limit = Number(url.searchParams.get("limit") ?? "50");
1516
+ const before = Number(url.searchParams.get("before") ?? "0");
1517
+ const page = await dirs.list(Number.isFinite(limit) ? limit : 50, Number.isFinite(before) ? before : 0);
1518
+ const names = options.handles ? await options.handles.many(page.addedBy) : new Map<string, string>();
1519
+ json(response, 200, {
1520
+ opendirs: page.rows.map((row, at) => ({
1521
+ ...row,
1522
+ // A handle or nothing. The address that signed up is never here.
1523
+ by: names.get(page.addedBy[at] ?? "") ?? "",
1524
+ })),
1525
+ next: page.next,
1526
+ });
1527
+ return;
1528
+ }
1529
+
1530
+ if (path === "/api/v1/opendirs" && request.method === "POST" && options.accounts) {
1531
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1532
+ if (who === null) {
1533
+ json(response, 401, { error: "sign in to publish one" });
1534
+ return;
1535
+ }
1536
+ let body: { url?: unknown; name?: unknown };
1537
+ try {
1538
+ body = JSON.parse(await readBody(request)) as typeof body;
1539
+ } catch {
1540
+ json(response, 400, { error: "bad JSON" });
1541
+ return;
1542
+ }
1543
+
1544
+ // Read before publishing. A row nobody can play is worse than no row,
1545
+ // and the count is the one fact a reader wants before clicking.
1546
+ const listed = typeof body.url === "string" ? await readRemoteIndex(body.url) : [];
1547
+ if (listed.length === 0) {
1548
+ json(response, 422, { error: "nothing playable was linked from that page" });
1549
+ return;
1550
+ }
1551
+ const made = await dirs.add(who.id, body.url, body.name, listed.length);
1552
+ if (made === null) {
1553
+ json(response, 422, { error: "that needs an http or https address" });
1554
+ return;
1555
+ }
1556
+ const handle = options.handles ? await options.handles.of(who.id) : "";
1557
+ json(response, 201, { opendir: { ...made, by: handle } });
1558
+ return;
1559
+ }
1560
+
1561
+ const dirId = path.slice("/api/v1/opendirs/".length);
1562
+ if (dirId && request.method === "DELETE" && options.accounts) {
1563
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1564
+ if (who === null) {
1565
+ json(response, 401, { error: "not signed in" });
1566
+ return;
1567
+ }
1568
+ const gone = await dirs.remove(who.id, dirId);
1569
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "not yours, or not there" });
1570
+ return;
1571
+ }
1572
+
1573
+ json(response, 405, { error: "GET, POST or DELETE" });
1574
+ return;
1575
+ }
1576
+
1498
1577
  // --- coming back from a provider ---------------------------------------
1499
1578
  //
1500
1579
  // /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
@@ -2678,7 +2757,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2678
2757
  // all: a browser already signed in can approve a terminal.
2679
2758
  // The same pool the follows and reminders use: three small tables in
2680
2759
  // one database do not want three sets of connections.
2681
- ...(pool ? { servers: new Servers(pool), handles: new Handles(pool) } : {}),
2760
+ ...(pool
2761
+ ? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
2762
+ : {}),
2682
2763
  signIn: new SignIn(
2683
2764
  providersFrom(process.env),
2684
2765
  new DeviceGrants(),