nixamp 0.6.4 → 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.
@@ -0,0 +1,58 @@
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
+ /**
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>>;
47
+ /** Who holds this handle, so a listing can name somebody without their address. */
48
+ holder(handle: string): Promise<string>;
49
+ /**
50
+ * Claim one. Answers the reason it could not be taken rather than a boolean,
51
+ * because "that is already somebody's" and "that is not a name" are different
52
+ * things to tell a person.
53
+ */
54
+ claim(userId: string, wanted: unknown): Promise<{
55
+ handle: string;
56
+ error: string;
57
+ }>;
58
+ }
@@ -0,0 +1,117 @@
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
+ // 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))
26
+ return "";
27
+ // Doubled hyphens are how punycode marks an encoded label, so a handle with
28
+ // one in it can collide with an internationalised domain.
29
+ return wanted.includes("--") ? "" : wanted;
30
+ }
31
+ /**
32
+ * Names nobody may take, because a subdomain carrying one would impersonate the
33
+ * service or reach a machine we run.
34
+ */
35
+ const RESERVED = new Set([
36
+ "www", "api", "admin", "root", "nixamp", "mail", "smtp", "imap", "ns1", "ns2",
37
+ "static", "cdn", "assets", "app", "dev", "staging", "test", "support", "help",
38
+ "status", "blog", "directory", "login", "signup", "account", "settings", "me",
39
+ ]);
40
+ export function isReserved(handle) {
41
+ return RESERVED.has(handle);
42
+ }
43
+ /**
44
+ * A handle for somebody who has not chosen one.
45
+ *
46
+ * Deliberately not derived from the address. "anthony@profullstack.com" turning
47
+ * into "anthony" is exactly the leak this whole file exists to avoid, and it
48
+ * would be a leak nobody noticed until it was already in a directory listing.
49
+ */
50
+ export function anonymousHandle(random) {
51
+ const bytes = random(4);
52
+ let out = "";
53
+ for (const byte of bytes)
54
+ out += byte.toString(16).padStart(2, "0");
55
+ return `nixamp-${out}`;
56
+ }
57
+ export class Handles {
58
+ db;
59
+ ready = null;
60
+ constructor(db) {
61
+ this.db = db;
62
+ }
63
+ async ensure() {
64
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
65
+ await this.ready;
66
+ }
67
+ async of(userId) {
68
+ await this.ensure();
69
+ const { rows } = await this.db.query(`SELECT handle FROM ${TABLE} WHERE user_id = $1`, [userId]);
70
+ return rows[0] ? String(rows[0]["handle"] ?? "") : "";
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
+ }
88
+ /** Who holds this handle, so a listing can name somebody without their address. */
89
+ async holder(handle) {
90
+ const wanted = cleanHandle(handle);
91
+ if (!wanted)
92
+ return "";
93
+ await this.ensure();
94
+ const { rows } = await this.db.query(`SELECT user_id FROM ${TABLE} WHERE lower(handle) = $1`, [wanted]);
95
+ return rows[0] ? String(rows[0]["user_id"] ?? "") : "";
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, wanted) {
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))
108
+ return { handle: "", error: "that one is reserved" };
109
+ await this.ensure();
110
+ const taken = await this.holder(handle);
111
+ if (taken && taken !== userId)
112
+ return { handle: "", error: "somebody already has that one" };
113
+ await this.db.query(`INSERT INTO ${TABLE} (user_id, handle) VALUES ($1, $2)
114
+ ON CONFLICT (user_id) DO UPDATE SET handle = EXCLUDED.handle, updated_at = NOW()`, [userId, handle]);
115
+ return { handle, error: "" };
116
+ }
117
+ }
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
+ }
@@ -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,8 @@ 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";
8
+ import { OpenDirs } from "./opendirs.ts";
7
9
  import { Servers } from "./servers.ts";
8
10
  import { SignIn } from "./oauth.ts";
9
11
  import { Owner } from "./owner.ts";
@@ -269,6 +271,10 @@ export interface HandlerOptions {
269
271
  signIn?: SignIn;
270
272
  /** The servers each account runs, on the instance that keeps accounts. */
271
273
  servers?: Servers;
274
+ /** The name other people see, which is never the address they signed up with. */
275
+ handles?: Handles;
276
+ /** Open directories people have found, which anyone may read. */
277
+ openDirs?: OpenDirs;
272
278
  /** True when this instance is reached over https, for the cookie's Secure. */
273
279
  secureCookies?: boolean;
274
280
  /** A certificate and key in PEM, when this server is to speak https itself. */