nixamp 0.7.0 → 0.7.1

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/handles.d.ts CHANGED
@@ -35,6 +35,15 @@ export declare class Handles {
35
35
  constructor(db: Queryable);
36
36
  private ensure;
37
37
  of(userId: string): Promise<string>;
38
+ /**
39
+ * Handles for a page of rows, in one query.
40
+ *
41
+ * A public listing names people, and naming them one query at a time is how a
42
+ * list of fifty becomes fifty round trips. Anyone without a handle is absent
43
+ * from the map rather than present as an empty string, so a caller decides
44
+ * what to show for somebody who never picked one.
45
+ */
46
+ many(userIds: string[]): Promise<Map<string, string>>;
38
47
  /** Who holds this handle, so a listing can name somebody without their address. */
39
48
  holder(handle: string): Promise<string>;
40
49
  /**
package/dist/handles.js CHANGED
@@ -20,7 +20,9 @@ export function cleanHandle(value) {
20
20
  if (typeof value !== "string")
21
21
  return "";
22
22
  const wanted = value.trim().toLowerCase();
23
- if (!/^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(wanted))
23
+ // Two characters minimum, thirty maximum: the trailing character is required,
24
+ // which is also what stops a handle ending in a hyphen.
25
+ if (!/^[a-z0-9][a-z0-9-]{0,28}[a-z0-9]$/.test(wanted))
24
26
  return "";
25
27
  // Doubled hyphens are how punycode marks an encoded label, so a handle with
26
28
  // one in it can collide with an internationalised domain.
@@ -67,6 +69,22 @@ export class Handles {
67
69
  const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
68
70
  return rows[0] ? String(rows[0]["handle"] ?? "") : "";
69
71
  }
72
+ /**
73
+ * Handles for a page of rows, in one query.
74
+ *
75
+ * A public listing names people, and naming them one query at a time is how a
76
+ * list of fifty becomes fifty round trips. Anyone without a handle is absent
77
+ * from the map rather than present as an empty string, so a caller decides
78
+ * what to show for somebody who never picked one.
79
+ */
80
+ async many(userIds) {
81
+ const wanted = [...new Set(userIds.filter(Boolean))];
82
+ if (wanted.length === 0)
83
+ return new Map();
84
+ await this.ensure();
85
+ const { rows } = await this.db.query(`SELECT user_id, handle FROM ${TABLE} WHERE user_id = ANY($1)`, [wanted]);
86
+ return new Map(rows.map((row) => [String(row["user_id"] ?? ""), String(row["handle"] ?? "")]));
87
+ }
70
88
  /** Who holds this handle, so a listing can name somebody without their address. */
71
89
  async holder(handle) {
72
90
  const wanted = cleanHandle(handle);
package/dist/main.js CHANGED
@@ -52,6 +52,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
52
52
  nixamp logout / whoami forget it, or check it
53
53
  nixamp token create|list|revoke tokens for a machine that cannot sign in
54
54
  nixamp server list|add|remove the machines you run, kept against your account
55
+ nixamp opendir list|add|remove folders found on the web, published for everyone
55
56
  nixamp update [version] re-run the installer, keeping your choices
56
57
  nixamp uninstall [--yes] remove everything the installer created
57
58
 
@@ -292,6 +293,11 @@ export async function main() {
292
293
  process.exitCode = await login(first === "signup" ? [...rest, "--signup"] : rest);
293
294
  return;
294
295
  }
296
+ if (first === "opendir" || first === "opendirs") {
297
+ const { opendirs } = await import("./session.js");
298
+ process.exitCode = await opendirs(rest);
299
+ return;
300
+ }
295
301
  if (first === "server" || first === "servers") {
296
302
  const { servers } = await import("./session.js");
297
303
  process.exitCode = await servers(rest);
@@ -0,0 +1,43 @@
1
+ import type { Queryable } from "./follows.ts";
2
+ export interface OpenDir {
3
+ id: string;
4
+ url: string;
5
+ name: string;
6
+ /** How many playable files were on the page when it was added. */
7
+ tracks: number;
8
+ /** The finder's public handle. Never their address. */
9
+ by: string;
10
+ createdAt: number;
11
+ }
12
+ /**
13
+ * The name to show for a folder nobody named.
14
+ *
15
+ * The last path segment, which for a folder of music is the album, written the
16
+ * way a person wrote it rather than the way a URL spells it.
17
+ */
18
+ export declare function nameOfDir(url: string): string;
19
+ export declare class OpenDirs {
20
+ private readonly db;
21
+ private ready;
22
+ constructor(db: Queryable);
23
+ private ensure;
24
+ /**
25
+ * The list, newest first, in pages.
26
+ *
27
+ * Keyset on the row's own creation time rather than an offset, because a list
28
+ * anybody may add to shifts under a reader who is paging through it, and an
29
+ * offset in a shifting list repeats and skips rows.
30
+ */
31
+ list(limit?: number, before?: number): Promise<{
32
+ rows: Omit<OpenDir, "by">[];
33
+ addedBy: string[];
34
+ next: number;
35
+ }>;
36
+ /**
37
+ * Publish one. `tracks` is what the finder's server actually saw on the page,
38
+ * so a row nobody can play never reaches the list.
39
+ */
40
+ add(userId: string, url: unknown, name: unknown, tracks: number): Promise<Omit<OpenDir, "by"> | null>;
41
+ /** Only the finder takes their own row down. */
42
+ remove(userId: string, id: string): Promise<boolean>;
43
+ }
@@ -0,0 +1,133 @@
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 { cleanUrl } from "./servers.js";
18
+ const TABLE = "nixamp_opendirs";
19
+ const SCHEMA = `
20
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
21
+ id TEXT PRIMARY KEY,
22
+ url TEXT NOT NULL,
23
+ name TEXT NOT NULL DEFAULT '',
24
+ added_by TEXT NOT NULL,
25
+ tracks INTEGER NOT NULL DEFAULT 0,
26
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
27
+ );
28
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_url ON ${TABLE} (url);
29
+ CREATE INDEX IF NOT EXISTS ${TABLE}_added ON ${TABLE} (created_at DESC);
30
+ `;
31
+ /**
32
+ * The name to show for a folder nobody named.
33
+ *
34
+ * The last path segment, which for a folder of music is the album, written the
35
+ * way a person wrote it rather than the way a URL spells it.
36
+ */
37
+ export function nameOfDir(url) {
38
+ try {
39
+ const parts = new URL(url).pathname.split("/").filter(Boolean);
40
+ const last = parts[parts.length - 1];
41
+ return last ? decodeURIComponent(last).slice(0, 120) : new URL(url).host;
42
+ }
43
+ catch {
44
+ return "an open directory";
45
+ }
46
+ }
47
+ function asTime(value) {
48
+ if (value instanceof Date)
49
+ return value.getTime();
50
+ if (typeof value === "string") {
51
+ const at = Date.parse(value);
52
+ return Number.isNaN(at) ? 0 : at;
53
+ }
54
+ return typeof value === "number" ? value : 0;
55
+ }
56
+ export class OpenDirs {
57
+ db;
58
+ ready = null;
59
+ constructor(db) {
60
+ this.db = db;
61
+ }
62
+ async ensure() {
63
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
64
+ await this.ready;
65
+ }
66
+ /**
67
+ * The list, newest first, in pages.
68
+ *
69
+ * Keyset on the row's own creation time rather than an offset, because a list
70
+ * anybody may add to shifts under a reader who is paging through it, and an
71
+ * offset in a shifting list repeats and skips rows.
72
+ */
73
+ async list(limit = 50, before = 0) {
74
+ await this.ensure();
75
+ const size = Math.min(200, Math.max(1, limit));
76
+ const { rows } = await this.db.query(`SELECT id, url, name, added_by, tracks, created_at FROM ${TABLE}
77
+ ${before > 0 ? "WHERE created_at < $2" : ""}
78
+ ORDER BY created_at DESC LIMIT $1`, before > 0 ? [size + 1, new Date(before).toISOString()] : [size + 1]);
79
+ // One more than asked for, so "is there another page" is a fact rather
80
+ // than a guess from a full one.
81
+ const page = rows.slice(0, size);
82
+ return {
83
+ rows: page.map((row) => ({
84
+ id: String(row["id"] ?? ""),
85
+ url: String(row["url"] ?? ""),
86
+ name: String(row["name"] ?? ""),
87
+ tracks: Number(row["tracks"] ?? 0),
88
+ createdAt: asTime(row["created_at"]),
89
+ })),
90
+ addedBy: page.map((row) => String(row["added_by"] ?? "")),
91
+ next: rows.length > size ? asTime(page[page.length - 1]?.["created_at"]) : 0,
92
+ };
93
+ }
94
+ /**
95
+ * Publish one. `tracks` is what the finder's server actually saw on the page,
96
+ * so a row nobody can play never reaches the list.
97
+ */
98
+ async add(userId, url, name, tracks) {
99
+ const clean = typeof url === "string" ? url.trim() : "";
100
+ // Not cleanUrl's origin-only treatment: a folder is a path, and the path is
101
+ // the whole point of the row.
102
+ if (!/^https?:\/\//i.test(clean) || cleanUrl(clean) === "")
103
+ return null;
104
+ if (tracks <= 0)
105
+ return null;
106
+ await this.ensure();
107
+ const { rows } = await this.db.query(`INSERT INTO ${TABLE} (id, url, name, added_by, tracks) VALUES ($1, $2, $3, $4, $5)
108
+ ON CONFLICT (url) DO UPDATE SET name = EXCLUDED.name, tracks = EXCLUDED.tracks
109
+ RETURNING id, url, name, tracks, created_at`, [
110
+ randomBytes(8).toString("hex"),
111
+ clean,
112
+ (typeof name === "string" && name.trim() ? name.trim() : nameOfDir(clean)).slice(0, 120),
113
+ userId,
114
+ Math.min(100_000, tracks),
115
+ ]);
116
+ const row = rows[0];
117
+ if (!row)
118
+ return null;
119
+ return {
120
+ id: String(row["id"] ?? ""),
121
+ url: String(row["url"] ?? ""),
122
+ name: String(row["name"] ?? ""),
123
+ tracks: Number(row["tracks"] ?? 0),
124
+ createdAt: asTime(row["created_at"]),
125
+ };
126
+ }
127
+ /** Only the finder takes their own row down. */
128
+ async remove(userId, id) {
129
+ await this.ensure();
130
+ const { rows } = await this.db.query(`DELETE FROM ${TABLE} WHERE id = $1 AND added_by = $2 RETURNING id`, [id, userId]);
131
+ return rows.length > 0;
132
+ }
133
+ }
package/dist/server.d.ts CHANGED
@@ -5,6 +5,7 @@ import { Ingest } from "./ingest.ts";
5
5
  import { Channels } from "./channels.ts";
6
6
  import { Accounts } from "./accounts.ts";
7
7
  import { Handles } from "./handles.ts";
8
+ import { OpenDirs } from "./opendirs.ts";
8
9
  import { Servers } from "./servers.ts";
9
10
  import { SignIn } from "./oauth.ts";
10
11
  import { Owner } from "./owner.ts";
@@ -272,6 +273,8 @@ export interface HandlerOptions {
272
273
  servers?: Servers;
273
274
  /** The name other people see, which is never the address they signed up with. */
274
275
  handles?: Handles;
276
+ /** Open directories people have found, which anyone may read. */
277
+ openDirs?: OpenDirs;
275
278
  /** True when this instance is reached over https, for the cookie's Secure. */
276
279
  secureCookies?: boolean;
277
280
  /** A certificate and key in PEM, when this server is to speak https itself. */
package/dist/server.js CHANGED
@@ -23,6 +23,7 @@ import { Channels, cleanId } from "./channels.js";
23
23
  import { RtmpListeners } from "./rtmp-in.js";
24
24
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
25
25
  import { anonymousHandle, Handles } from "./handles.js";
26
+ import { OpenDirs } from "./opendirs.js";
26
27
  import { Servers } from "./servers.js";
27
28
  import { DeviceGrants } from "./device.js";
28
29
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
@@ -45,7 +46,7 @@ import { extname, join, normalize, resolve, sep } from "node:path";
45
46
  import { fileURLToPath } from "node:url";
46
47
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
47
48
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
48
- import { loadSource, loadTagged } from "./playlist.js";
49
+ import { loadSource, loadTagged, readRemoteIndex } from "./playlist.js";
49
50
  import { emptySnapshot, parseCommand, } from "./protocol.js";
50
51
  const FFT_SIZE = 2048;
51
52
  export const SERVE_BAND_COUNT = 24;
@@ -558,6 +559,9 @@ export function isSignInPath(path) {
558
559
  path === "/api/v1/servers" ||
559
560
  path.startsWith("/api/v1/servers/") ||
560
561
  path === "/api/v1/me/handle" ||
562
+ // Public to read, so it must not be behind a share key either.
563
+ path === "/api/v1/opendirs" ||
564
+ path.startsWith("/api/v1/opendirs/") ||
561
565
  OAUTH_ROUTE.test(path));
562
566
  }
563
567
  function html(response, code, body) {
@@ -1216,6 +1220,74 @@ export function createHandler(engine, options) {
1216
1220
  response.end(JSON.stringify({ account: result.account, token }));
1217
1221
  return;
1218
1222
  }
1223
+ // --- open directories somebody found -----------------------------------
1224
+ //
1225
+ // Its own list, not the stream directory: that one is what is playing now,
1226
+ // with a heartbeat and a room code and somebody at the other end, and this
1227
+ // one is a folder on the web that is always there and belongs to nobody
1228
+ // here. Reading needs no account. Adding does, because a public list with
1229
+ // nobody accountable for its rows is a list of whatever anyone felt like.
1230
+ if ((path === "/api/v1/opendirs" || path.startsWith("/api/v1/opendirs/")) && options.openDirs) {
1231
+ const dirs = options.openDirs;
1232
+ if (path === "/api/v1/opendirs" && request.method === "GET") {
1233
+ const limit = Number(url.searchParams.get("limit") ?? "50");
1234
+ const before = Number(url.searchParams.get("before") ?? "0");
1235
+ const page = await dirs.list(Number.isFinite(limit) ? limit : 50, Number.isFinite(before) ? before : 0);
1236
+ const names = options.handles ? await options.handles.many(page.addedBy) : new Map();
1237
+ json(response, 200, {
1238
+ opendirs: page.rows.map((row, at) => ({
1239
+ ...row,
1240
+ // A handle or nothing. The address that signed up is never here.
1241
+ by: names.get(page.addedBy[at] ?? "") ?? "",
1242
+ })),
1243
+ next: page.next,
1244
+ });
1245
+ return;
1246
+ }
1247
+ if (path === "/api/v1/opendirs" && request.method === "POST" && options.accounts) {
1248
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1249
+ if (who === null) {
1250
+ json(response, 401, { error: "sign in to publish one" });
1251
+ return;
1252
+ }
1253
+ let body;
1254
+ try {
1255
+ body = JSON.parse(await readBody(request));
1256
+ }
1257
+ catch {
1258
+ json(response, 400, { error: "bad JSON" });
1259
+ return;
1260
+ }
1261
+ // Read before publishing. A row nobody can play is worse than no row,
1262
+ // and the count is the one fact a reader wants before clicking.
1263
+ const listed = typeof body.url === "string" ? await readRemoteIndex(body.url) : [];
1264
+ if (listed.length === 0) {
1265
+ json(response, 422, { error: "nothing playable was linked from that page" });
1266
+ return;
1267
+ }
1268
+ const made = await dirs.add(who.id, body.url, body.name, listed.length);
1269
+ if (made === null) {
1270
+ json(response, 422, { error: "that needs an http or https address" });
1271
+ return;
1272
+ }
1273
+ const handle = options.handles ? await options.handles.of(who.id) : "";
1274
+ json(response, 201, { opendir: { ...made, by: handle } });
1275
+ return;
1276
+ }
1277
+ const dirId = path.slice("/api/v1/opendirs/".length);
1278
+ if (dirId && request.method === "DELETE" && options.accounts) {
1279
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1280
+ if (who === null) {
1281
+ json(response, 401, { error: "not signed in" });
1282
+ return;
1283
+ }
1284
+ const gone = await dirs.remove(who.id, dirId);
1285
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "not yours, or not there" });
1286
+ return;
1287
+ }
1288
+ json(response, 405, { error: "GET, POST or DELETE" });
1289
+ return;
1290
+ }
1219
1291
  // --- coming back from a provider ---------------------------------------
1220
1292
  //
1221
1293
  // /api/v1/<provider>/oauth/start sends a browser away, and .../callback is
@@ -2324,7 +2396,9 @@ export async function serve(argv, version = "0.1.0") {
2324
2396
  // all: a browser already signed in can approve a terminal.
2325
2397
  // The same pool the follows and reminders use: three small tables in
2326
2398
  // one database do not want three sets of connections.
2327
- ...(pool ? { servers: new Servers(pool), handles: new Handles(pool) } : {}),
2399
+ ...(pool
2400
+ ? { servers: new Servers(pool), handles: new Handles(pool), openDirs: new OpenDirs(pool) }
2401
+ : {}),
2328
2402
  signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
2329
2403
  }
2330
2404
  : {}),
package/dist/session.d.ts CHANGED
@@ -107,3 +107,11 @@ export declare function whoami(fetcher?: typeof fetch): Promise<number>;
107
107
  * the browser and in the desktop app.
108
108
  */
109
109
  export declare function servers(argv: string[], fetcher?: typeof fetch): Promise<number>;
110
+ /**
111
+ * `nixamp opendir list|add|remove`, the public list of folders people found.
112
+ *
113
+ * Reading needs no account, which is why `list` works signed out. Adding needs
114
+ * one, because a public list with nobody accountable for its rows is a list of
115
+ * whatever anybody felt like putting there.
116
+ */
117
+ export declare function opendirs(argv: string[], fetcher?: typeof fetch): Promise<number>;
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.1",
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. */
@@ -1495,6 +1501,79 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1495
1501
  return;
1496
1502
  }
1497
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(),
package/src/session.ts CHANGED
@@ -659,3 +659,103 @@ interface ServerRow {
659
659
  url: string;
660
660
  key: string;
661
661
  }
662
+
663
+ /** One row of the public open-directory list, as the API sends it. */
664
+ interface OpenDirRow {
665
+ id: string;
666
+ url: string;
667
+ name: string;
668
+ tracks: number;
669
+ by: string;
670
+ }
671
+
672
+ /**
673
+ * `nixamp opendir list|add|remove`, the public list of folders people found.
674
+ *
675
+ * Reading needs no account, which is why `list` works signed out. Adding needs
676
+ * one, because a public list with nobody accountable for its rows is a list of
677
+ * whatever anybody felt like putting there.
678
+ */
679
+ export async function opendirs(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
680
+ const [command = "list", ...rest] = argv;
681
+ const session = readSession();
682
+ const site = session?.site ?? DEFAULT_DIRECTORY;
683
+ const where = `${site}/api/v1/opendirs`;
684
+ const headers: Record<string, string> = {
685
+ "content-type": "application/json",
686
+ ...(session ? { authorization: `Bearer ${session.token}` } : {}),
687
+ };
688
+ const at = (flag: string): string | undefined => {
689
+ const index = rest.indexOf(flag);
690
+ return index === -1 ? undefined : rest[index + 1];
691
+ };
692
+
693
+ try {
694
+ if (command === "list" || command === "ls") {
695
+ const answer = await fetcher(where, { headers });
696
+ const body = (await answer.json().catch(() => ({}))) as { opendirs?: OpenDirRow[]; error?: string };
697
+ if (!answer.ok) {
698
+ console.error(`nixamp: ${body.error ?? `could not read the list (${answer.status})`}`);
699
+ return 1;
700
+ }
701
+ const rows = body.opendirs ?? [];
702
+ if (rows.length === 0) {
703
+ console.log("Nothing published yet. `nixamp opendir add <url>` puts one there.");
704
+ return 0;
705
+ }
706
+ const width = Math.max(...rows.map((row) => row.name.length));
707
+ for (const row of rows) {
708
+ const who = row.by ? ` by ${row.by}` : "";
709
+ console.log(`${row.id} ${row.name.padEnd(width)} ${row.tracks} tracks${who}`);
710
+ console.log(` ${row.url}`);
711
+ }
712
+ return 0;
713
+ }
714
+
715
+ if (session === null) {
716
+ console.error("nixamp: sign in to publish or remove one. Try `nixamp login`.");
717
+ return 1;
718
+ }
719
+
720
+ if (command === "add" || command === "publish") {
721
+ const target = rest.find((a) => /^https?:\/\//.test(a)) ?? "";
722
+ if (!target) {
723
+ console.error("nixamp: which folder? Give the URL of a directory listing.");
724
+ return 64;
725
+ }
726
+ const answer = await fetcher(where, {
727
+ method: "POST",
728
+ headers,
729
+ body: JSON.stringify({ url: target, name: at("--name") ?? "" }),
730
+ });
731
+ const body = (await answer.json().catch(() => ({}))) as { opendir?: OpenDirRow; error?: string };
732
+ if (!answer.ok || !body.opendir) {
733
+ console.error(`nixamp: ${body.error ?? `could not publish it (${answer.status})`}`);
734
+ return 1;
735
+ }
736
+ console.log(`${body.opendir.id} ${body.opendir.name} ${body.opendir.tracks} tracks`);
737
+ return 0;
738
+ }
739
+
740
+ if (command === "remove" || command === "rm") {
741
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
742
+ if (!id) {
743
+ console.error("nixamp: which one? `nixamp opendir list` shows their ids.");
744
+ return 64;
745
+ }
746
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
747
+ if (!answer.ok) {
748
+ console.error(`nixamp: ${answer.status === 404 ? "not yours, or not there" : "could not remove it"}`);
749
+ return 1;
750
+ }
751
+ console.log(`Removed ${id}.`);
752
+ return 0;
753
+ }
754
+
755
+ console.error(`nixamp: no such opendir command: ${command}`);
756
+ return 64;
757
+ } catch (error) {
758
+ console.error(`nixamp: could not reach ${site}: ${(error as Error).message}`);
759
+ return 69;
760
+ }
761
+ }
@@ -173,5 +173,6 @@
173
173
  <a href="https://github.com/profullstack/nixamp">github.com/profullstack/nixamp</a>
174
174
  </footer>
175
175
  </main>
176
+ <script data-site="f6b791a3-13e0-4212-a2c9-abafc3b7aff0" src="https://crawlproof.com/stats.js" async></script>
176
177
  </body>
177
178
  </html>
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788945979682";
2
+ const CACHE = "nixamp-1788946637747";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",