nixamp 0.6.4 → 0.7.0

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The name other people see.
3
+ *
4
+ * An account is keyed on an email address, because that is what the auth module
5
+ * authenticates. An address is a credential and a way to reach somebody, and it
6
+ * is not a name: putting it in a directory listing, an invite or a subdomain
7
+ * publishes something the account holder gave us to log in with.
8
+ *
9
+ * So there are two names. The address stays private and does the linking, and a
10
+ * handle is the one that appears in front of strangers. They are never the same
11
+ * field and never travel in the same response.
12
+ */
13
+ import type { Queryable } from "./follows.ts";
14
+ /**
15
+ * What a handle may be.
16
+ *
17
+ * It ends up in a URL, a subdomain and a text message, so it is the intersection
18
+ * of what all three tolerate: lowercase letters, digits and hyphens, not
19
+ * starting or ending with one. Two to thirty characters, because a subdomain
20
+ * label cannot exceed sixty-three and nobody types thirty.
21
+ */
22
+ export declare function cleanHandle(value: unknown): string;
23
+ export declare function isReserved(handle: string): boolean;
24
+ /**
25
+ * A handle for somebody who has not chosen one.
26
+ *
27
+ * Deliberately not derived from the address. "anthony@profullstack.com" turning
28
+ * into "anthony" is exactly the leak this whole file exists to avoid, and it
29
+ * would be a leak nobody noticed until it was already in a directory listing.
30
+ */
31
+ export declare function anonymousHandle(random: (size: number) => Uint8Array): string;
32
+ export declare class Handles {
33
+ private readonly db;
34
+ private ready;
35
+ constructor(db: Queryable);
36
+ private ensure;
37
+ of(userId: string): Promise<string>;
38
+ /** Who holds this handle, so a listing can name somebody without their address. */
39
+ holder(handle: string): Promise<string>;
40
+ /**
41
+ * Claim one. Answers the reason it could not be taken rather than a boolean,
42
+ * because "that is already somebody's" and "that is not a name" are different
43
+ * things to tell a person.
44
+ */
45
+ claim(userId: string, wanted: unknown): Promise<{
46
+ handle: string;
47
+ error: string;
48
+ }>;
49
+ }
@@ -0,0 +1,99 @@
1
+ const TABLE = "nixamp_handles";
2
+ const SCHEMA = `
3
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
4
+ user_id TEXT PRIMARY KEY,
5
+ handle TEXT NOT NULL,
6
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
8
+ );
9
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
10
+ `;
11
+ /**
12
+ * What a handle may be.
13
+ *
14
+ * It ends up in a URL, a subdomain and a text message, so it is the intersection
15
+ * of what all three tolerate: lowercase letters, digits and hyphens, not
16
+ * starting or ending with one. Two to thirty characters, because a subdomain
17
+ * label cannot exceed sixty-three and nobody types thirty.
18
+ */
19
+ export function cleanHandle(value) {
20
+ if (typeof value !== "string")
21
+ return "";
22
+ const wanted = value.trim().toLowerCase();
23
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(wanted))
24
+ return "";
25
+ // Doubled hyphens are how punycode marks an encoded label, so a handle with
26
+ // one in it can collide with an internationalised domain.
27
+ return wanted.includes("--") ? "" : wanted;
28
+ }
29
+ /**
30
+ * Names nobody may take, because a subdomain carrying one would impersonate the
31
+ * service or reach a machine we run.
32
+ */
33
+ const RESERVED = new Set([
34
+ "www", "api", "admin", "root", "nixamp", "mail", "smtp", "imap", "ns1", "ns2",
35
+ "static", "cdn", "assets", "app", "dev", "staging", "test", "support", "help",
36
+ "status", "blog", "directory", "login", "signup", "account", "settings", "me",
37
+ ]);
38
+ export function isReserved(handle) {
39
+ return RESERVED.has(handle);
40
+ }
41
+ /**
42
+ * A handle for somebody who has not chosen one.
43
+ *
44
+ * Deliberately not derived from the address. "anthony@profullstack.com" turning
45
+ * into "anthony" is exactly the leak this whole file exists to avoid, and it
46
+ * would be a leak nobody noticed until it was already in a directory listing.
47
+ */
48
+ export function anonymousHandle(random) {
49
+ const bytes = random(4);
50
+ let out = "";
51
+ for (const byte of bytes)
52
+ out += byte.toString(16).padStart(2, "0");
53
+ return `nixamp-${out}`;
54
+ }
55
+ export class Handles {
56
+ db;
57
+ ready = null;
58
+ constructor(db) {
59
+ this.db = db;
60
+ }
61
+ async ensure() {
62
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
63
+ await this.ready;
64
+ }
65
+ async of(userId) {
66
+ await this.ensure();
67
+ const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
68
+ return rows[0] ? String(rows[0]["handle"] ?? "") : "";
69
+ }
70
+ /** Who holds this handle, so a listing can name somebody without their address. */
71
+ async holder(handle) {
72
+ const wanted = cleanHandle(handle);
73
+ if (!wanted)
74
+ return "";
75
+ await this.ensure();
76
+ const { rows } = await this.db.query(`SELECT user_id FROM ${TABLE} WHERE lower(handle) = $1`, [wanted]);
77
+ return rows[0] ? String(rows[0]["user_id"] ?? "") : "";
78
+ }
79
+ /**
80
+ * Claim one. Answers the reason it could not be taken rather than a boolean,
81
+ * because "that is already somebody's" and "that is not a name" are different
82
+ * things to tell a person.
83
+ */
84
+ async claim(userId, wanted) {
85
+ const handle = cleanHandle(wanted);
86
+ if (!handle) {
87
+ return { handle: "", error: "letters, digits and hyphens, 2 to 30 characters" };
88
+ }
89
+ if (isReserved(handle))
90
+ return { handle: "", error: "that one is reserved" };
91
+ await this.ensure();
92
+ const taken = await this.holder(handle);
93
+ if (taken && taken !== userId)
94
+ return { handle: "", error: "somebody already has that one" };
95
+ await this.db.query(`INSERT INTO ${TABLE} (user_id, handle) VALUES ($1, $2)
96
+ ON CONFLICT (user_id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = NOW()`, [userId, handle]);
97
+ return { handle, error: "" };
98
+ }
99
+ }
@@ -10,6 +10,19 @@ export declare function findAudio(root: string): string[];
10
10
  * ffmpeg is left to do what it is good at.
11
11
  */
12
12
  export declare function readPlaylist(source: string): Promise<Entry[]>;
13
+ /**
14
+ * The audio linked from a directory listing a web server generated.
15
+ *
16
+ * A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
17
+ * of relative links. Handed one of those, nixamp used to make a single track of
18
+ * the page itself and give it to ffmpeg, which is asked to decode HTML and says
19
+ * so in a way nobody reads. It is a folder; it should behave like one.
20
+ *
21
+ * Not recursive, deliberately: one page is one album, the subdirectory links are
22
+ * on it, and walking a stranger's whole tree from a text box is a different and
23
+ * much larger thing to ask for.
24
+ */
25
+ export declare function readRemoteIndex(source: string, send?: typeof fetch): Promise<Entry[]>;
13
26
  /**
14
27
  * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
15
28
  * or a URL to any of those.
package/dist/playlist.js CHANGED
@@ -81,6 +81,69 @@ export async function readPlaylist(source) {
81
81
  const entries = /\.pls$/i.test(source) ? parsePls(text, source) : parseM3u(text, source);
82
82
  return entries;
83
83
  }
84
+ /**
85
+ * The audio linked from a directory listing a web server generated.
86
+ *
87
+ * A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
88
+ * of relative links. Handed one of those, nixamp used to make a single track of
89
+ * the page itself and give it to ffmpeg, which is asked to decode HTML and says
90
+ * so in a way nobody reads. It is a folder; it should behave like one.
91
+ *
92
+ * Not recursive, deliberately: one page is one album, the subdirectory links are
93
+ * on it, and walking a stranger's whole tree from a text box is a different and
94
+ * much larger thing to ask for.
95
+ */
96
+ export async function readRemoteIndex(source, send = fetch) {
97
+ let answer;
98
+ try {
99
+ answer = await send(source, {
100
+ redirect: "follow",
101
+ headers: { accept: "text/html,*/*" },
102
+ signal: AbortSignal.timeout(15_000),
103
+ });
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ if (!answer.ok)
109
+ return [];
110
+ // Anything that is not a page is the thing itself, and the caller plays it.
111
+ if (!/^text\/html/i.test(answer.headers.get("content-type") ?? ""))
112
+ return [];
113
+ const html = await answer.text().catch(() => "");
114
+ const found = [];
115
+ const seen = new Set();
116
+ for (const match of html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
117
+ const href = match[1];
118
+ // The sort links an index puts at the top of every column, and anchors.
119
+ if (!href || href.startsWith("?") || href.startsWith("#"))
120
+ continue;
121
+ let url;
122
+ try {
123
+ // Relative to the page, which is how an index writes them.
124
+ url = new URL(href.replace(/&amp;/g, "&"), source);
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ if (!isAudio(url.pathname))
130
+ continue;
131
+ const link = url.toString();
132
+ if (seen.has(link))
133
+ continue;
134
+ seen.add(link);
135
+ found.push({
136
+ source: link,
137
+ // The name as a person wrote it, not as a URL spells it.
138
+ title: decodeURIComponent(url.pathname.split("/").pop() ?? link),
139
+ duration: 0,
140
+ });
141
+ }
142
+ // Server order is by whatever column the index sorted on; by name is what
143
+ // somebody handing over an album meant.
144
+ found.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
145
+ return found;
146
+ }
84
147
  /**
85
148
  * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
86
149
  * or a URL to any of those.
@@ -104,10 +167,19 @@ export async function loadSource(tools, source, probeTags = true) {
104
167
  ? { ...probe(tools, entry.source), title: entry.title || probe(tools, entry.source).title }
105
168
  : bare(entry));
106
169
  }
107
- // A bare URL is one remote thing to play. Whether it is a song or a live
108
- // stream is ffmpeg's problem, and it is good at it.
109
- if (isRemote(source))
170
+ if (isRemote(source)) {
171
+ // A URL that names no file is probably a folder, and a folder served over
172
+ // http is a page of links. Asked only when it could be one: a stream URL
173
+ // must not pay for a fetch that will tell us nothing.
174
+ if (!isAudio(new URL(source).pathname)) {
175
+ const listed = await readRemoteIndex(source);
176
+ if (listed.length > 0)
177
+ return listed.map(bare);
178
+ }
179
+ // A bare URL is one remote thing to play. Whether it is a song or a live
180
+ // stream is ffmpeg's problem, and it is good at it.
110
181
  return [bare({ source, title: nameOf(source), duration: 0 })];
182
+ }
111
183
  return loadPlaylist(tools, source, probeTags);
112
184
  }
113
185
  /**
package/dist/server.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast
4
4
  import { Ingest } from "./ingest.ts";
5
5
  import { Channels } from "./channels.ts";
6
6
  import { Accounts } from "./accounts.ts";
7
+ import { Handles } from "./handles.ts";
7
8
  import { Servers } from "./servers.ts";
8
9
  import { SignIn } from "./oauth.ts";
9
10
  import { Owner } from "./owner.ts";
@@ -269,6 +270,8 @@ export interface HandlerOptions {
269
270
  signIn?: SignIn;
270
271
  /** The servers each account runs, on the instance that keeps accounts. */
271
272
  servers?: Servers;
273
+ /** The name other people see, which is never the address they signed up with. */
274
+ handles?: Handles;
272
275
  /** True when this instance is reached over https, for the cookie's Secure. */
273
276
  secureCookies?: boolean;
274
277
  /** A certificate and key in PEM, when this server is to speak https itself. */
package/dist/server.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer } from "node:http";
14
14
  import { createServer as createHttpsServer } from "node:https";
15
+ import { randomBytes } from "node:crypto";
15
16
  import { hostname } from "node:os";
16
17
  import { spawn, spawnSync } from "node:child_process";
17
18
  import { readFileSync } from "node:fs";
@@ -21,6 +22,7 @@ import { Ingest, normaliseFormat } from "./ingest.js";
21
22
  import { Channels, cleanId } from "./channels.js";
22
23
  import { RtmpListeners } from "./rtmp-in.js";
23
24
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
25
+ import { anonymousHandle, Handles } from "./handles.js";
24
26
  import { Servers } from "./servers.js";
25
27
  import { DeviceGrants } from "./device.js";
26
28
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
@@ -555,6 +557,7 @@ export function isSignInPath(path) {
555
557
  // rather than by a share key it has nothing to do with.
556
558
  path === "/api/v1/servers" ||
557
559
  path.startsWith("/api/v1/servers/") ||
560
+ path === "/api/v1/me/handle" ||
558
561
  OAUTH_ROUTE.test(path));
559
562
  }
560
563
  function html(response, code, body) {
@@ -994,6 +997,42 @@ export function createHandler(engine, options) {
994
997
  json(response, 404, { error: "no such endpoint" });
995
998
  return;
996
999
  }
1000
+ // --- the name other people see ---------------------------------------
1001
+ //
1002
+ // Separate from the address on purpose. The address is a credential and
1003
+ // a way to reach somebody; publishing it in a directory listing or an
1004
+ // invite would be publishing what they log in with.
1005
+ if (path === "/api/v1/me/handle" && options.handles) {
1006
+ const handles = options.handles;
1007
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1008
+ if (who === null) {
1009
+ json(response, 401, { error: "not signed in" });
1010
+ return;
1011
+ }
1012
+ if (request.method === "GET") {
1013
+ json(response, 200, { handle: await handles.of(who.id) });
1014
+ return;
1015
+ }
1016
+ if (request.method === "PUT" || request.method === "POST") {
1017
+ let body;
1018
+ try {
1019
+ body = JSON.parse(await readBody(request));
1020
+ }
1021
+ catch {
1022
+ json(response, 400, { error: "bad JSON" });
1023
+ return;
1024
+ }
1025
+ const claimed = await handles.claim(who.id, body.handle);
1026
+ if (claimed.error) {
1027
+ json(response, 409, { error: claimed.error });
1028
+ return;
1029
+ }
1030
+ json(response, 200, { handle: claimed.handle });
1031
+ return;
1032
+ }
1033
+ json(response, 405, { error: "GET or PUT" });
1034
+ return;
1035
+ }
997
1036
  // --- the servers this account runs ----------------------------------
998
1037
  //
999
1038
  // Kept against the account rather than the machine, so the list reads the
@@ -1140,6 +1179,17 @@ export function createHandler(engine, options) {
1140
1179
  const result = signingUp
1141
1180
  ? await accounts.signUp(body.email, body.password)
1142
1181
  : await accounts.signIn(body.email, body.password);
1182
+ // A handle asked for at sign-up, or one nobody has to think about. Never
1183
+ // derived from the address: turning anthony@… into "anthony" is the leak
1184
+ // this whole idea exists to avoid, and it is one nobody would notice
1185
+ // until it was already in a directory listing.
1186
+ if (signingUp && result.ok && result.account && options.handles) {
1187
+ const asked = body.handle;
1188
+ const claimed = await options.handles.claim(result.account.id, asked);
1189
+ if (claimed.error) {
1190
+ await options.handles.claim(result.account.id, anonymousHandle((size) => randomBytes(size)));
1191
+ }
1192
+ }
1143
1193
  // Getting it right costs nothing: the counters only exist to stop people
1144
1194
  // who keep getting it wrong.
1145
1195
  if (result.ok)
@@ -2274,7 +2324,7 @@ export async function serve(argv, version = "0.1.0") {
2274
2324
  // all: a browser already signed in can approve a terminal.
2275
2325
  // The same pool the follows and reminders use: three small tables in
2276
2326
  // one database do not want three sets of connections.
2277
- ...(pool ? { servers: new Servers(pool) } : {}),
2327
+ ...(pool ? { servers: new Servers(pool), handles: new Handles(pool) } : {}),
2278
2328
  signIn: new SignIn(providersFrom(process.env), new DeviceGrants(), process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY),
2279
2329
  }
2280
2330
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
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 ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The name other people see.
3
+ *
4
+ * An account is keyed on an email address, because that is what the auth module
5
+ * authenticates. An address is a credential and a way to reach somebody, and it
6
+ * is not a name: putting it in a directory listing, an invite or a subdomain
7
+ * publishes something the account holder gave us to log in with.
8
+ *
9
+ * So there are two names. The address stays private and does the linking, and a
10
+ * handle is the one that appears in front of strangers. They are never the same
11
+ * field and never travel in the same response.
12
+ */
13
+ import type { Queryable } from "./follows.ts";
14
+
15
+ const TABLE = "nixamp_handles";
16
+
17
+ const SCHEMA = `
18
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
19
+ user_id TEXT PRIMARY KEY,
20
+ handle TEXT NOT NULL,
21
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
22
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
23
+ );
24
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
25
+ `;
26
+
27
+ /**
28
+ * What a handle may be.
29
+ *
30
+ * It ends up in a URL, a subdomain and a text message, so it is the intersection
31
+ * of what all three tolerate: lowercase letters, digits and hyphens, not
32
+ * starting or ending with one. Two to thirty characters, because a subdomain
33
+ * label cannot exceed sixty-three and nobody types thirty.
34
+ */
35
+ export function cleanHandle(value: unknown): string {
36
+ if (typeof value !== "string") return "";
37
+ const wanted = value.trim().toLowerCase();
38
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(wanted)) return "";
39
+ // Doubled hyphens are how punycode marks an encoded label, so a handle with
40
+ // one in it can collide with an internationalised domain.
41
+ return wanted.includes("--") ? "" : wanted;
42
+ }
43
+
44
+ /**
45
+ * Names nobody may take, because a subdomain carrying one would impersonate the
46
+ * service or reach a machine we run.
47
+ */
48
+ const RESERVED = new Set([
49
+ "www", "api", "admin", "root", "nixamp", "mail", "smtp", "imap", "ns1", "ns2",
50
+ "static", "cdn", "assets", "app", "dev", "staging", "test", "support", "help",
51
+ "status", "blog", "directory", "login", "signup", "account", "settings", "me",
52
+ ]);
53
+
54
+ export function isReserved(handle: string): boolean {
55
+ return RESERVED.has(handle);
56
+ }
57
+
58
+ /**
59
+ * A handle for somebody who has not chosen one.
60
+ *
61
+ * Deliberately not derived from the address. "anthony@profullstack.com" turning
62
+ * into "anthony" is exactly the leak this whole file exists to avoid, and it
63
+ * would be a leak nobody noticed until it was already in a directory listing.
64
+ */
65
+ export function anonymousHandle(random: (size: number) => Uint8Array): string {
66
+ const bytes = random(4);
67
+ let out = "";
68
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
69
+ return `nixamp-${out}`;
70
+ }
71
+
72
+ export class Handles {
73
+ private ready: Promise<void> | null = null;
74
+
75
+ constructor(private readonly db: Queryable) {}
76
+
77
+ private async ensure(): Promise<void> {
78
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
79
+ await this.ready;
80
+ }
81
+
82
+ async of(userId: string): Promise<string> {
83
+ await this.ensure();
84
+ const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
85
+ return rows[0] ? String(rows[0]["handle"] ?? "") : "";
86
+ }
87
+
88
+ /** Who holds this handle, so a listing can name somebody without their address. */
89
+ async holder(handle: string): Promise<string> {
90
+ const wanted = cleanHandle(handle);
91
+ if (!wanted) return "";
92
+ await this.ensure();
93
+ const { rows } = await this.db.query(`SELECT user_id FROM ${TABLE} WHERE lower(handle) = $1`, [wanted]);
94
+ return rows[0] ? String(rows[0]["user_id"] ?? "") : "";
95
+ }
96
+
97
+ /**
98
+ * Claim one. Answers the reason it could not be taken rather than a boolean,
99
+ * because "that is already somebody's" and "that is not a name" are different
100
+ * things to tell a person.
101
+ */
102
+ async claim(userId: string, wanted: unknown): Promise<{ handle: string; error: string }> {
103
+ const handle = cleanHandle(wanted);
104
+ if (!handle) {
105
+ return { handle: "", error: "letters, digits and hyphens, 2 to 30 characters" };
106
+ }
107
+ if (isReserved(handle)) return { handle: "", error: "that one is reserved" };
108
+
109
+ await this.ensure();
110
+ const taken = await this.holder(handle);
111
+ if (taken && taken !== userId) return { handle: "", error: "somebody already has that one" };
112
+
113
+ await this.db.query(
114
+ `INSERT INTO ${TABLE} (user_id, handle) VALUES ($1, $2)
115
+ ON CONFLICT (user_id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = NOW()`,
116
+ [userId, handle],
117
+ );
118
+ return { handle, error: "" };
119
+ }
120
+ }
package/src/playlist.ts CHANGED
@@ -87,6 +87,64 @@ export async function readPlaylist(source: string): Promise<Entry[]> {
87
87
  return entries;
88
88
  }
89
89
 
90
+ /**
91
+ * The audio linked from a directory listing a web server generated.
92
+ *
93
+ * A seedbox or a plain Apache with autoindex on serves a folder as an HTML page
94
+ * of relative links. Handed one of those, nixamp used to make a single track of
95
+ * the page itself and give it to ffmpeg, which is asked to decode HTML and says
96
+ * so in a way nobody reads. It is a folder; it should behave like one.
97
+ *
98
+ * Not recursive, deliberately: one page is one album, the subdirectory links are
99
+ * on it, and walking a stranger's whole tree from a text box is a different and
100
+ * much larger thing to ask for.
101
+ */
102
+ export async function readRemoteIndex(source: string, send: typeof fetch = fetch): Promise<Entry[]> {
103
+ let answer: Response;
104
+ try {
105
+ answer = await send(source, {
106
+ redirect: "follow",
107
+ headers: { accept: "text/html,*/*" },
108
+ signal: AbortSignal.timeout(15_000),
109
+ });
110
+ } catch {
111
+ return [];
112
+ }
113
+ if (!answer.ok) return [];
114
+ // Anything that is not a page is the thing itself, and the caller plays it.
115
+ if (!/^text\/html/i.test(answer.headers.get("content-type") ?? "")) return [];
116
+
117
+ const html = await answer.text().catch(() => "");
118
+ const found: Entry[] = [];
119
+ const seen = new Set<string>();
120
+ for (const match of html.matchAll(/href\s*=\s*["']([^"']+)["']/gi)) {
121
+ const href = match[1];
122
+ // The sort links an index puts at the top of every column, and anchors.
123
+ if (!href || href.startsWith("?") || href.startsWith("#")) continue;
124
+ let url: URL;
125
+ try {
126
+ // Relative to the page, which is how an index writes them.
127
+ url = new URL(href.replace(/&amp;/g, "&"), source);
128
+ } catch {
129
+ continue;
130
+ }
131
+ if (!isAudio(url.pathname)) continue;
132
+ const link = url.toString();
133
+ if (seen.has(link)) continue;
134
+ seen.add(link);
135
+ found.push({
136
+ source: link,
137
+ // The name as a person wrote it, not as a URL spells it.
138
+ title: decodeURIComponent(url.pathname.split("/").pop() ?? link),
139
+ duration: 0,
140
+ });
141
+ }
142
+ // Server order is by whatever column the index sorted on; by name is what
143
+ // somebody handing over an album meant.
144
+ found.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }));
145
+ return found;
146
+ }
147
+
90
148
  /**
91
149
  * Everything `nixamp <thing>` can be handed: a directory, a file, a playlist,
92
150
  * or a URL to any of those.
@@ -112,9 +170,18 @@ export async function loadSource(tools: Tools, source: string, probeTags = true)
112
170
  );
113
171
  }
114
172
 
115
- // A bare URL is one remote thing to play. Whether it is a song or a live
116
- // stream is ffmpeg's problem, and it is good at it.
117
- if (isRemote(source)) return [bare({ source, title: nameOf(source), duration: 0 })];
173
+ if (isRemote(source)) {
174
+ // A URL that names no file is probably a folder, and a folder served over
175
+ // http is a page of links. Asked only when it could be one: a stream URL
176
+ // must not pay for a fetch that will tell us nothing.
177
+ if (!isAudio(new URL(source).pathname)) {
178
+ const listed = await readRemoteIndex(source);
179
+ if (listed.length > 0) return listed.map(bare);
180
+ }
181
+ // A bare URL is one remote thing to play. Whether it is a song or a live
182
+ // stream is ffmpeg's problem, and it is good at it.
183
+ return [bare({ source, title: nameOf(source), duration: 0 })];
184
+ }
118
185
 
119
186
  return loadPlaylist(tools, source, probeTags);
120
187
  }
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { createReadStream, statSync } from "node:fs";
13
13
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
14
  import { createServer as createHttpsServer } from "node:https";
15
+ import { randomBytes } from "node:crypto";
15
16
  import { hostname, networkInterfaces } from "node:os";
16
17
  import { spawn, spawnSync } from "node:child_process";
17
18
  import { readFileSync } from "node:fs";
@@ -28,6 +29,7 @@ import { Ingest, normaliseFormat } from "./ingest.ts";
28
29
  import { Channels, cleanId } from "./channels.ts";
29
30
  import { RtmpListeners } from "./rtmp-in.ts";
30
31
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
32
+ import { anonymousHandle, Handles } from "./handles.ts";
31
33
  import { Servers } from "./servers.ts";
32
34
  import { DeviceGrants } from "./device.ts";
33
35
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
@@ -692,6 +694,7 @@ export function isSignInPath(path: string): boolean {
692
694
  // rather than by a share key it has nothing to do with.
693
695
  path === "/api/v1/servers" ||
694
696
  path.startsWith("/api/v1/servers/") ||
697
+ path === "/api/v1/me/handle" ||
695
698
  OAUTH_ROUTE.test(path)
696
699
  );
697
700
  }
@@ -778,6 +781,8 @@ export interface HandlerOptions {
778
781
  signIn?: SignIn;
779
782
  /** The servers each account runs, on the instance that keeps accounts. */
780
783
  servers?: Servers;
784
+ /** The name other people see, which is never the address they signed up with. */
785
+ handles?: Handles;
781
786
  /** True when this instance is reached over https, for the cookie's Secure. */
782
787
  secureCookies?: boolean;
783
788
  /** A certificate and key in PEM, when this server is to speak https itself. */
@@ -1256,6 +1261,43 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1256
1261
  return;
1257
1262
  }
1258
1263
 
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
+
1259
1301
  // --- the servers this account runs ----------------------------------
1260
1302
  //
1261
1303
  // Kept against the account rather than the machine, so the list reads the
@@ -1413,6 +1455,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1413
1455
  ? await accounts.signUp(body.email, body.password)
1414
1456
  : await accounts.signIn(body.email, body.password);
1415
1457
 
1458
+ // A handle asked for at sign-up, or one nobody has to think about. Never
1459
+ // derived from the address: turning anthony@… into "anthony" is the leak
1460
+ // this whole idea exists to avoid, and it is one nobody would notice
1461
+ // until it was already in a directory listing.
1462
+ if (signingUp && result.ok && result.account && options.handles) {
1463
+ const asked = (body as { handle?: unknown }).handle;
1464
+ const claimed = await options.handles.claim(result.account.id, asked);
1465
+ if (claimed.error) {
1466
+ await options.handles.claim(result.account.id, anonymousHandle((size) => randomBytes(size)));
1467
+ }
1468
+ }
1469
+
1416
1470
  // Getting it right costs nothing: the counters only exist to stop people
1417
1471
  // who keep getting it wrong.
1418
1472
  if (result.ok) for (const bucket of buckets) guard.forget(bucket);
@@ -2624,7 +2678,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2624
2678
  // all: a browser already signed in can approve a terminal.
2625
2679
  // The same pool the follows and reminders use: three small tables in
2626
2680
  // one database do not want three sets of connections.
2627
- ...(pool ? { servers: new Servers(pool) } : {}),
2681
+ ...(pool ? { servers: new Servers(pool), handles: new Handles(pool) } : {}),
2628
2682
  signIn: new SignIn(
2629
2683
  providersFrom(process.env),
2630
2684
  new DeviceGrants(),
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788945118927";
2
+ const CACHE = "nixamp-1788945979682";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",