nixamp 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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) {
@@ -997,109 +1001,6 @@ export function createHandler(engine, options) {
997
1001
  json(response, 404, { error: "no such endpoint" });
998
1002
  return;
999
1003
  }
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
- }
1036
- // --- the servers this account runs ----------------------------------
1037
- //
1038
- // Kept against the account rather than the machine, so the list reads the
1039
- // same from the CLI, the PWA and the desktop app -- which is the whole
1040
- // point: a share link in a terminal you closed is a server you have lost.
1041
- if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
1042
- const servers = options.servers;
1043
- const who = await accounts.whoIs(tokenFrom(request.headers));
1044
- if (who === null) {
1045
- json(response, 401, { error: "not signed in" });
1046
- return;
1047
- }
1048
- if (path === "/api/v1/servers" && request.method === "GET") {
1049
- json(response, 200, { servers: await servers.list(who.id) });
1050
- return;
1051
- }
1052
- if (path === "/api/v1/servers" && request.method === "POST") {
1053
- let body;
1054
- try {
1055
- body = JSON.parse(await readBody(request));
1056
- }
1057
- catch {
1058
- json(response, 400, { error: "bad JSON" });
1059
- return;
1060
- }
1061
- const made = await servers.add(who, {
1062
- ...(typeof body.name === "string" ? { name: body.name } : {}),
1063
- ...(typeof body.url === "string" ? { url: body.url } : {}),
1064
- ...(typeof body.key === "string" ? { key: body.key } : {}),
1065
- });
1066
- if (made === null) {
1067
- json(response, 422, { error: "that needs an http or https address" });
1068
- return;
1069
- }
1070
- json(response, 201, { server: made });
1071
- return;
1072
- }
1073
- const id = path.slice("/api/v1/servers/".length);
1074
- if (id && request.method === "DELETE") {
1075
- const gone = await servers.remove(who.id, id);
1076
- json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1077
- return;
1078
- }
1079
- if (id && (request.method === "PATCH" || request.method === "PUT")) {
1080
- let body;
1081
- try {
1082
- body = JSON.parse(await readBody(request));
1083
- }
1084
- catch {
1085
- json(response, 400, { error: "bad JSON" });
1086
- return;
1087
- }
1088
- const changed = await servers.update(who.id, id, {
1089
- ...(typeof body.name === "string" ? { name: body.name } : {}),
1090
- ...(typeof body.url === "string" ? { url: body.url } : {}),
1091
- ...(typeof body.key === "string" ? { key: body.key } : {}),
1092
- });
1093
- if (changed === null) {
1094
- json(response, 404, { error: "no such server, or a bad address" });
1095
- return;
1096
- }
1097
- json(response, 200, { server: changed });
1098
- return;
1099
- }
1100
- json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1101
- return;
1102
- }
1103
1004
  // --- tokens a person made on purpose --------------------------------
1104
1005
  if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
1105
1006
  const who = await accounts.whoIs(tokenFrom(request.headers));
@@ -1216,6 +1117,177 @@ export function createHandler(engine, options) {
1216
1117
  response.end(JSON.stringify({ account: result.account, token }));
1217
1118
  return;
1218
1119
  }
1120
+ // --- the name other people see ---------------------------------------
1121
+ //
1122
+ // Separate from the address on purpose. The address is a credential and
1123
+ // a way to reach somebody; publishing it in a directory listing or an
1124
+ // invite would be publishing what they log in with.
1125
+ if (path === "/api/v1/me/handle" && options.handles && options.accounts) {
1126
+ const handles = options.handles;
1127
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1128
+ if (who === null) {
1129
+ json(response, 401, { error: "not signed in" });
1130
+ return;
1131
+ }
1132
+ if (request.method === "GET") {
1133
+ json(response, 200, { handle: await handles.of(who.id) });
1134
+ return;
1135
+ }
1136
+ if (request.method === "PUT" || request.method === "POST") {
1137
+ let body;
1138
+ try {
1139
+ body = JSON.parse(await readBody(request));
1140
+ }
1141
+ catch {
1142
+ json(response, 400, { error: "bad JSON" });
1143
+ return;
1144
+ }
1145
+ const claimed = await handles.claim(who.id, body.handle);
1146
+ if (claimed.error) {
1147
+ json(response, 409, { error: claimed.error });
1148
+ return;
1149
+ }
1150
+ json(response, 200, { handle: claimed.handle });
1151
+ return;
1152
+ }
1153
+ json(response, 405, { error: "GET or PUT" });
1154
+ return;
1155
+ }
1156
+ // --- the servers this account runs ----------------------------------
1157
+ //
1158
+ // Kept against the account rather than the machine, so the list reads the
1159
+ // same from the CLI, the PWA and the desktop app -- which is the whole
1160
+ // point: a share link in a terminal you closed is a server you have lost.
1161
+ if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers && options.accounts) {
1162
+ const servers = options.servers;
1163
+ const who = await options.accounts.whoIs(tokenFrom(request.headers));
1164
+ if (who === null) {
1165
+ json(response, 401, { error: "not signed in" });
1166
+ return;
1167
+ }
1168
+ if (path === "/api/v1/servers" && request.method === "GET") {
1169
+ json(response, 200, { servers: await servers.list(who.id) });
1170
+ return;
1171
+ }
1172
+ if (path === "/api/v1/servers" && request.method === "POST") {
1173
+ let body;
1174
+ try {
1175
+ body = JSON.parse(await readBody(request));
1176
+ }
1177
+ catch {
1178
+ json(response, 400, { error: "bad JSON" });
1179
+ return;
1180
+ }
1181
+ const made = await servers.add(who, {
1182
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1183
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1184
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1185
+ });
1186
+ if (made === null) {
1187
+ json(response, 422, { error: "that needs an http or https address" });
1188
+ return;
1189
+ }
1190
+ json(response, 201, { server: made });
1191
+ return;
1192
+ }
1193
+ const id = path.slice("/api/v1/servers/".length);
1194
+ if (id && request.method === "DELETE") {
1195
+ const gone = await servers.remove(who.id, id);
1196
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1197
+ return;
1198
+ }
1199
+ if (id && (request.method === "PATCH" || request.method === "PUT")) {
1200
+ let body;
1201
+ try {
1202
+ body = JSON.parse(await readBody(request));
1203
+ }
1204
+ catch {
1205
+ json(response, 400, { error: "bad JSON" });
1206
+ return;
1207
+ }
1208
+ const changed = await servers.update(who.id, id, {
1209
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1210
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1211
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1212
+ });
1213
+ if (changed === null) {
1214
+ json(response, 404, { error: "no such server, or a bad address" });
1215
+ return;
1216
+ }
1217
+ json(response, 200, { server: changed });
1218
+ return;
1219
+ }
1220
+ json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1221
+ return;
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>;