nixamp 0.28.41 → 0.28.42

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.
Files changed (33) hide show
  1. package/dist/ads.d.ts +11 -0
  2. package/dist/ads.js +73 -0
  3. package/dist/remote-subject.d.ts +42 -0
  4. package/dist/remote-subject.js +149 -0
  5. package/dist/server.js +54 -1
  6. package/package.json +1 -1
  7. package/src/ads.ts +84 -0
  8. package/src/remote-subject.ts +166 -0
  9. package/src/server.ts +59 -1
  10. package/web/dist/assets/{ar-BqLaLSMR.js → ar-NNtPSh0T.js} +1 -1
  11. package/web/dist/assets/{cs-CfoQJwKM.js → cs-CoDuxGFp.js} +1 -1
  12. package/web/dist/assets/{da-8MmH23fh.js → da-CIzoYCq-.js} +1 -1
  13. package/web/dist/assets/{de-Brry3OMh.js → de-D1hhSKwo.js} +1 -1
  14. package/web/dist/assets/{es-CGxJs7b8.js → es-DrrFYoGX.js} +1 -1
  15. package/web/dist/assets/{fi-BDAAZtto.js → fi-CzddkEge.js} +1 -1
  16. package/web/dist/assets/{fr-9sxYL0Qy.js → fr-CDWlgPE2.js} +1 -1
  17. package/web/dist/assets/{hi-Cc7ZsdL7.js → hi-D5DI3VOz.js} +1 -1
  18. package/web/dist/assets/{hls-3VKVEQE3-Dx7qltzA.js → hls-3VKVEQE3-B23esRBf.js} +1 -1
  19. package/web/dist/assets/{hu-CvB9aIyN.js → hu-nFsc-Mn0.js} +1 -1
  20. package/web/dist/assets/{id-Bmrh06Hw.js → id-95FiAAmz.js} +1 -1
  21. package/web/dist/assets/index-C9HOvMut.js +3 -0
  22. package/web/dist/assets/{it-SbaxFUIP.js → it-CqQmls-8.js} +1 -1
  23. package/web/dist/assets/{mpegts-LO6RVLD6-CqW3DT1n.js → mpegts-LO6RVLD6-BXCXHAVe.js} +1 -1
  24. package/web/dist/assets/{mpegts-BdwONu8t.js → mpegts-nqTu3Ia6.js} +1 -1
  25. package/web/dist/assets/{nl-CqimSo3B.js → nl-CyK7Lw-S.js} +1 -1
  26. package/web/dist/assets/{ru-wIfpMpU9.js → ru-CZ2Sf0im.js} +1 -1
  27. package/web/dist/assets/{sv-BAyIUHP5.js → sv-Wz7vgFh5.js} +1 -1
  28. package/web/dist/assets/{uk-D2jFJm6l.js → uk-Dp4ppXNY.js} +1 -1
  29. package/web/dist/assets/{vi-BdH3SLlR.js → vi-Bx_2Qd-6.js} +1 -1
  30. package/web/dist/assets/{zh-5xz5Agt-.js → zh-D0kAlzs8.js} +1 -1
  31. package/web/dist/index.html +1 -1
  32. package/web/dist/sw.js +22 -22
  33. package/web/dist/assets/index-BQuoiKg9.js +0 -3
package/dist/ads.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type Advert = {
2
+ url: string;
3
+ kind: "audio" | "video";
4
+ } | {
5
+ url: null;
6
+ };
7
+ export declare function nextAdvert(kindParam: string | null, deps?: {
8
+ fetchImpl?: typeof fetch;
9
+ origin?: string;
10
+ slot?: string;
11
+ }): Promise<Advert>;
package/dist/ads.js ADDED
@@ -0,0 +1,73 @@
1
+ // Where an advert for a break comes from.
2
+ //
3
+ // nixamp does not decide which advert plays, or record that it played. Both
4
+ // belong to the ad network: it runs the auction and meters the impression, and
5
+ // a second opinion here would be a second set of numbers. This module is a
6
+ // proxy with an opinion about failure, nothing more.
7
+ //
8
+ // The opinion is that a break nobody can fill does not happen. The player
9
+ // treats anything without a url as "no advert" and keeps playing music, which
10
+ // is the only behaviour worth defaulting to when the alternative is silence in
11
+ // somebody's ears.
12
+ /** The network's break endpoint. Overridable so a deployment can point elsewhere. */
13
+ const AD_ORIGIN = process.env.AD_ORIGIN ?? "https://crawlproof.com";
14
+ /**
15
+ * This property's slot at the network.
16
+ *
17
+ * Without it there is nothing to ask about, and asking anyway would spend a
18
+ * request per break to be told the same thing. Unset means adverts are off for
19
+ * this deployment, which is the right default for somebody running nixamp on
20
+ * their own machine: their listeners are themselves.
21
+ */
22
+ const AD_SLOT = process.env.AD_SLOT ?? "";
23
+ /**
24
+ * How long to wait.
25
+ *
26
+ * A break is a gap in the music, so the budget is what a listener will not
27
+ * notice. Past it the advert is simply not worth having — the player falls back
28
+ * to content, which is a better outcome than a pause while a third party thinks
29
+ * about it.
30
+ */
31
+ const TIMEOUT_MS = 1500;
32
+ /** No advert. The shape the player expects, not an error. */
33
+ const NONE = { url: null };
34
+ export async function nextAdvert(kindParam, deps = {}) {
35
+ const slot = deps.slot ?? AD_SLOT;
36
+ if (!slot)
37
+ return NONE;
38
+ // Audio unless a caller says otherwise. nixamp is a music player: the artwork
39
+ // stays where it is and a video creative has nowhere to go.
40
+ const kind = kindParam === "video" ? "video" : "audio";
41
+ const doFetch = deps.fetchImpl ?? fetch;
42
+ const origin = deps.origin ?? AD_ORIGIN;
43
+ // AbortSignal.timeout rather than a race, so the socket is actually closed
44
+ // rather than left running behind a promise nobody reads.
45
+ try {
46
+ const res = await doFetch(`${origin}/api/ads/stream?slot=${encodeURIComponent(slot)}&kind=${kind}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(TIMEOUT_MS) });
47
+ if (!res.ok)
48
+ return NONE;
49
+ const body = (await res.json());
50
+ // Only https, and only a real string. This url is handed to a media element
51
+ // in somebody's browser, so a javascript: or data: value arriving from the
52
+ // network must not survive being proxied through here.
53
+ if (typeof body.url !== "string")
54
+ return NONE;
55
+ let parsed;
56
+ try {
57
+ parsed = new URL(body.url);
58
+ }
59
+ catch {
60
+ return NONE;
61
+ }
62
+ if (parsed.protocol !== "https:")
63
+ return NONE;
64
+ return {
65
+ url: parsed.toString(),
66
+ kind: body.kind === "video" ? "video" : "audio",
67
+ };
68
+ }
69
+ catch {
70
+ // A timeout, a refused connection, malformed JSON. All the same answer.
71
+ return NONE;
72
+ }
73
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Naming a join link by asking the server it points at.
3
+ *
4
+ * A link preview is read before any script runs, so the shell has to say what
5
+ * is on the air. When the linked server is in this directory, its listing says
6
+ * so and joinSubject answers from that. When it is not — somebody's own nixamp,
7
+ * shared directly — there was nothing to say and the card fell back to the
8
+ * site's generic title.
9
+ *
10
+ * The rule that produced that fallback is worth keeping: a name taken from the
11
+ * query string would let whoever wrote the link choose how the card reads on
12
+ * somebody else's domain, which is a phishing primitive rather than a feature.
13
+ * So the name is not taken from the query. It is fetched from the server the
14
+ * link points at, which can only describe itself.
15
+ *
16
+ * The share key travels in the link, and a server that is key-gated answers
17
+ * only with it. Using it here grants nothing new: whoever holds the link
18
+ * already holds the key.
19
+ */
20
+ /** What a join card needs, matching joinSubject's shape. */
21
+ export interface RemoteSubject {
22
+ title: string;
23
+ where: string;
24
+ image?: string;
25
+ kind?: "audio" | "video";
26
+ }
27
+ /** The share key a link carries, from /view/<key> or ?k=. */
28
+ export declare function keyFromLink(link: URL): string;
29
+ /**
30
+ * Ask the linked server what it is called, and what the wanted channel is
31
+ * called on it.
32
+ *
33
+ * Returns null for every failure — an unreachable server, a refusal, a shape
34
+ * that is not what we expect, an address we will not fetch. The caller then
35
+ * renders the generic shell, which is what it did before.
36
+ */
37
+ export declare function remoteSubject(linkHref: string, wantedChannel: string, deps?: {
38
+ fetchImpl?: typeof fetch;
39
+ now?: () => number;
40
+ }): Promise<RemoteSubject | null>;
41
+ /** Testing seam: the cache outlives a request by design. */
42
+ export declare function __clearRemoteSubjectCache(): void;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Naming a join link by asking the server it points at.
3
+ *
4
+ * A link preview is read before any script runs, so the shell has to say what
5
+ * is on the air. When the linked server is in this directory, its listing says
6
+ * so and joinSubject answers from that. When it is not — somebody's own nixamp,
7
+ * shared directly — there was nothing to say and the card fell back to the
8
+ * site's generic title.
9
+ *
10
+ * The rule that produced that fallback is worth keeping: a name taken from the
11
+ * query string would let whoever wrote the link choose how the card reads on
12
+ * somebody else's domain, which is a phishing primitive rather than a feature.
13
+ * So the name is not taken from the query. It is fetched from the server the
14
+ * link points at, which can only describe itself.
15
+ *
16
+ * The share key travels in the link, and a server that is key-gated answers
17
+ * only with it. Using it here grants nothing new: whoever holds the link
18
+ * already holds the key.
19
+ */
20
+ /**
21
+ * How long to wait for a server to name itself.
22
+ *
23
+ * This runs before the page is sent, so it is a budget for somebody waiting on
24
+ * a tab to open. A server that is slow, asleep or gone simply does not name the
25
+ * card, which is exactly what happened before this existed.
26
+ */
27
+ const TIMEOUT_MS = 1200;
28
+ /**
29
+ * How long an answer is reused.
30
+ *
31
+ * A link shared into a busy channel is fetched once per crawler that unfurls
32
+ * it, and they arrive together. A minute is long enough to collapse that into
33
+ * one request and short enough that renaming a channel shows up while somebody
34
+ * is still looking at it.
35
+ */
36
+ const TTL_MS = 60_000;
37
+ const cache = new Map();
38
+ /**
39
+ * Hosts a server must never be asked to fetch.
40
+ *
41
+ * The address comes from the query string, so without this the page is a probe
42
+ * anyone can point at anything this machine can reach — a cloud metadata
43
+ * endpoint, a database admin port, a service on the loopback interface. The
44
+ * cost of being wrong here is not a bad preview, it is an open proxy.
45
+ */
46
+ function isPubliclyRoutable(hostname) {
47
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
48
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local"))
49
+ return false;
50
+ // IPv6 loopback and the unique-local / link-local ranges.
51
+ if (host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
52
+ return false;
53
+ }
54
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
55
+ if (v4) {
56
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
57
+ if (a === 10 || a === 127 || a === 0)
58
+ return false;
59
+ if (a === 172 && b >= 16 && b <= 31)
60
+ return false;
61
+ if (a === 192 && b === 168)
62
+ return false;
63
+ if (a === 169 && b === 254)
64
+ return false; // link-local, and AWS metadata
65
+ if (a >= 224)
66
+ return false; // multicast and reserved
67
+ }
68
+ return true;
69
+ }
70
+ /** The share key a link carries, from /view/<key> or ?k=. */
71
+ export function keyFromLink(link) {
72
+ const viewed = /^\/view\/([^/]+)\/?$/.exec(link.pathname);
73
+ if (viewed?.[1])
74
+ return decodeURIComponent(viewed[1]);
75
+ return link.searchParams.get("k") ?? "";
76
+ }
77
+ /**
78
+ * Ask the linked server what it is called, and what the wanted channel is
79
+ * called on it.
80
+ *
81
+ * Returns null for every failure — an unreachable server, a refusal, a shape
82
+ * that is not what we expect, an address we will not fetch. The caller then
83
+ * renders the generic shell, which is what it did before.
84
+ */
85
+ export async function remoteSubject(linkHref, wantedChannel, deps = {}) {
86
+ let link;
87
+ try {
88
+ link = new URL(linkHref);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ // https only. An http link from an https page is blockable mixed content in
94
+ // the browser anyway, and fetching it here would be the one part of the
95
+ // journey that was not protected.
96
+ if (link.protocol !== "https:")
97
+ return null;
98
+ if (!isPubliclyRoutable(link.hostname))
99
+ return null;
100
+ const key = keyFromLink(link);
101
+ const cacheKey = `${link.origin}|${key}|${wantedChannel}`;
102
+ const now = deps.now ?? Date.now;
103
+ const hit = cache.get(cacheKey);
104
+ if (hit && now() - hit.at < TTL_MS)
105
+ return hit.subject;
106
+ const doFetch = deps.fetchImpl ?? fetch;
107
+ let subject = null;
108
+ try {
109
+ const asked = new URL("/api/streams", link.origin);
110
+ if (key !== "")
111
+ asked.searchParams.set("k", key);
112
+ const answer = await doFetch(asked.toString(), {
113
+ headers: { accept: "application/json" },
114
+ signal: AbortSignal.timeout(TIMEOUT_MS),
115
+ });
116
+ if (answer.ok) {
117
+ const body = (await answer.json());
118
+ const where = typeof body.server?.name === "string" ? body.server.name : "";
119
+ const channels = Array.isArray(body.channels) ? body.channels : [];
120
+ const found = wantedChannel === ""
121
+ ? undefined
122
+ : channels.find((one) => one.id === wantedChannel || one.name === wantedChannel);
123
+ if (found && typeof found.name === "string" && found.name !== "") {
124
+ subject = {
125
+ title: found.name,
126
+ where,
127
+ ...(typeof found.art === "string" && found.art !== "" ? { image: found.art } : {}),
128
+ ...(found.kind === "audio" || found.kind === "video" ? { kind: found.kind } : {}),
129
+ };
130
+ }
131
+ else if (wantedChannel === "" && where !== "") {
132
+ // No channel asked for: the server itself is the subject.
133
+ subject = { title: where, where: "" };
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ // Unreachable, refused, timed out, or not JSON. All the same answer.
139
+ subject = null;
140
+ }
141
+ // Failures are cached too, deliberately: a server that is down should not be
142
+ // re-asked by every crawler unfurling the same link in the same minute.
143
+ cache.set(cacheKey, { at: now(), subject });
144
+ return subject;
145
+ }
146
+ /** Testing seam: the cache outlives a request by design. */
147
+ export function __clearRemoteSubjectCache() {
148
+ cache.clear();
149
+ }
package/dist/server.js CHANGED
@@ -100,6 +100,8 @@ import { fileURLToPath } from "node:url";
100
100
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
101
101
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
102
102
  import { loadSource, loadTagged, readRemoteIndex } from "./playlist.js";
103
+ import { nextAdvert } from "./ads.js";
104
+ import { remoteSubject } from "./remote-subject.js";
103
105
  import { emptySnapshot, parseCommand, } from "./protocol.js";
104
106
  const FFT_SIZE = 2048;
105
107
  export const SERVE_BAND_COUNT = 24;
@@ -1146,6 +1148,13 @@ export function isSignInPath(path) {
1146
1148
  path.startsWith("/api/v1/me/profile/") ||
1147
1149
  // A stored photo is public: it is on every class the host runs.
1148
1150
  path.startsWith("/api/v1/profiles/") ||
1151
+ // An advert for a break. It has to answer without a key for the same
1152
+ // reason it exists: the listeners who get adverts are the ones who have
1153
+ // not signed in or paid.
1154
+ path === "/api/ads/next" ||
1155
+ // What this listener has paid for, asked before every break by the same
1156
+ // people and for the same reason, so it answers on the same terms.
1157
+ path === "/api/entitlements" ||
1149
1158
  // "Connect nixamp" on a site that is a client of nixamp.com.
1150
1159
  nixampLinkPath(path) ||
1151
1160
  // Public to read, so it must not be behind a share key either.
@@ -1948,6 +1957,37 @@ export function createHandler(engine, options) {
1948
1957
  void partyLine.handle(event.data ?? {}).catch(() => { });
1949
1958
  return;
1950
1959
  }
1960
+ // An advert for a break.
1961
+ //
1962
+ // Answered here, before the key check, because the listeners who get
1963
+ // adverts are precisely the ones with no key and no session. The player
1964
+ // asks for this when a break is due and treats anything but a url as "no
1965
+ // advert", so every failure below is a silent null: a break nobody can
1966
+ // fill simply does not happen and the listener keeps their music.
1967
+ //
1968
+ // Nothing about which advert to play is decided here. That is crawlproof's
1969
+ // auction, and it meters the impression — so this must not cache, retry, or
1970
+ // ask twice for one break.
1971
+ if (path === "/api/ads/next" && request.method === "GET") {
1972
+ json(response, 200, await nextAdvert(url.searchParams.get("kind")));
1973
+ return;
1974
+ }
1975
+ // What this listener has paid for.
1976
+ //
1977
+ // Nothing yet, and saying so is the point. The player asks this before
1978
+ // every break to decide whether to run one; with no route it asked, got a
1979
+ // 404 on every page load, and read the miss as "holds nothing" — the right
1980
+ // answer by the wrong road, and a console error that looked like a fault
1981
+ // to anybody who opened the devtools.
1982
+ //
1983
+ // An empty list is the honest reply while nixamp has no OpenAccess client:
1984
+ // there is no pass to hold, so nobody holds one. The shape is the one the
1985
+ // player already reads, so wiring the hub later is a change here and
1986
+ // nowhere else.
1987
+ if (path === "/api/entitlements" && request.method === "GET") {
1988
+ json(response, 200, { entitlements: [] });
1989
+ return;
1990
+ }
1951
1991
  // /api/health answers unauthenticated on purpose: it is how you check the
1952
1992
  // port is open from another device before wondering whether the link is
1953
1993
  // wrong, and it says nothing about the library.
@@ -5314,7 +5354,20 @@ export function createHandler(engine, options) {
5314
5354
  catch { }
5315
5355
  }
5316
5356
  if (file.endsWith("index.html") && path === "/") {
5317
- const subject = joinSubject(url, options);
5357
+ // The directory first: a listed server is named by the listing this
5358
+ // site already holds, with no request to anybody.
5359
+ let subject = joinSubject(url, options);
5360
+ if (subject === null) {
5361
+ // Not listed. Ask the server the link points at what it is called,
5362
+ // rather than reading a name out of the query — a name from the
5363
+ // query would let whoever wrote the link choose how the card reads
5364
+ // on this domain, which is a phishing primitive, not a feature.
5365
+ const link = url.searchParams.get("url") ?? "";
5366
+ const play = url.searchParams.get("play") ?? "";
5367
+ if (link !== "") {
5368
+ subject = await remoteSubject(link, play.startsWith("channel:") ? play.slice("channel:".length) : "");
5369
+ }
5370
+ }
5318
5371
  const shell = subject ? readIfPossible(file) : null;
5319
5372
  if (subject && shell !== null) {
5320
5373
  html(response, 200, joinDocument(shell, subject, eventSite ?? "https://nixamp.com"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.28.41",
3
+ "version": "0.28.42",
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/ads.ts ADDED
@@ -0,0 +1,84 @@
1
+ // Where an advert for a break comes from.
2
+ //
3
+ // nixamp does not decide which advert plays, or record that it played. Both
4
+ // belong to the ad network: it runs the auction and meters the impression, and
5
+ // a second opinion here would be a second set of numbers. This module is a
6
+ // proxy with an opinion about failure, nothing more.
7
+ //
8
+ // The opinion is that a break nobody can fill does not happen. The player
9
+ // treats anything without a url as "no advert" and keeps playing music, which
10
+ // is the only behaviour worth defaulting to when the alternative is silence in
11
+ // somebody's ears.
12
+
13
+ /** The network's break endpoint. Overridable so a deployment can point elsewhere. */
14
+ const AD_ORIGIN = process.env.AD_ORIGIN ?? "https://crawlproof.com";
15
+
16
+ /**
17
+ * This property's slot at the network.
18
+ *
19
+ * Without it there is nothing to ask about, and asking anyway would spend a
20
+ * request per break to be told the same thing. Unset means adverts are off for
21
+ * this deployment, which is the right default for somebody running nixamp on
22
+ * their own machine: their listeners are themselves.
23
+ */
24
+ const AD_SLOT = process.env.AD_SLOT ?? "";
25
+
26
+ /**
27
+ * How long to wait.
28
+ *
29
+ * A break is a gap in the music, so the budget is what a listener will not
30
+ * notice. Past it the advert is simply not worth having — the player falls back
31
+ * to content, which is a better outcome than a pause while a third party thinks
32
+ * about it.
33
+ */
34
+ const TIMEOUT_MS = 1500;
35
+
36
+ export type Advert = { url: string; kind: "audio" | "video" } | { url: null };
37
+
38
+ /** No advert. The shape the player expects, not an error. */
39
+ const NONE: Advert = { url: null };
40
+
41
+ export async function nextAdvert(
42
+ kindParam: string | null,
43
+ deps: { fetchImpl?: typeof fetch; origin?: string; slot?: string } = {},
44
+ ): Promise<Advert> {
45
+ const slot = deps.slot ?? AD_SLOT;
46
+ if (!slot) return NONE;
47
+
48
+ // Audio unless a caller says otherwise. nixamp is a music player: the artwork
49
+ // stays where it is and a video creative has nowhere to go.
50
+ const kind = kindParam === "video" ? "video" : "audio";
51
+ const doFetch = deps.fetchImpl ?? fetch;
52
+ const origin = deps.origin ?? AD_ORIGIN;
53
+
54
+ // AbortSignal.timeout rather than a race, so the socket is actually closed
55
+ // rather than left running behind a promise nobody reads.
56
+ try {
57
+ const res = await doFetch(
58
+ `${origin}/api/ads/stream?slot=${encodeURIComponent(slot)}&kind=${kind}`,
59
+ { headers: { accept: "application/json" }, signal: AbortSignal.timeout(TIMEOUT_MS) },
60
+ );
61
+ if (!res.ok) return NONE;
62
+
63
+ const body = (await res.json()) as { url?: unknown; kind?: unknown };
64
+ // Only https, and only a real string. This url is handed to a media element
65
+ // in somebody's browser, so a javascript: or data: value arriving from the
66
+ // network must not survive being proxied through here.
67
+ if (typeof body.url !== "string") return NONE;
68
+ let parsed: URL;
69
+ try {
70
+ parsed = new URL(body.url);
71
+ } catch {
72
+ return NONE;
73
+ }
74
+ if (parsed.protocol !== "https:") return NONE;
75
+
76
+ return {
77
+ url: parsed.toString(),
78
+ kind: body.kind === "video" ? "video" : "audio",
79
+ };
80
+ } catch {
81
+ // A timeout, a refused connection, malformed JSON. All the same answer.
82
+ return NONE;
83
+ }
84
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Naming a join link by asking the server it points at.
3
+ *
4
+ * A link preview is read before any script runs, so the shell has to say what
5
+ * is on the air. When the linked server is in this directory, its listing says
6
+ * so and joinSubject answers from that. When it is not — somebody's own nixamp,
7
+ * shared directly — there was nothing to say and the card fell back to the
8
+ * site's generic title.
9
+ *
10
+ * The rule that produced that fallback is worth keeping: a name taken from the
11
+ * query string would let whoever wrote the link choose how the card reads on
12
+ * somebody else's domain, which is a phishing primitive rather than a feature.
13
+ * So the name is not taken from the query. It is fetched from the server the
14
+ * link points at, which can only describe itself.
15
+ *
16
+ * The share key travels in the link, and a server that is key-gated answers
17
+ * only with it. Using it here grants nothing new: whoever holds the link
18
+ * already holds the key.
19
+ */
20
+
21
+ /** What /api/streams says about itself. Only the parts this file reads. */
22
+ interface StreamsAnswer {
23
+ server?: { name?: unknown };
24
+ channels?: { id?: unknown; name?: unknown; art?: unknown; kind?: unknown }[];
25
+ }
26
+
27
+ /** What a join card needs, matching joinSubject's shape. */
28
+ export interface RemoteSubject {
29
+ title: string;
30
+ where: string;
31
+ image?: string;
32
+ kind?: "audio" | "video";
33
+ }
34
+
35
+ /**
36
+ * How long to wait for a server to name itself.
37
+ *
38
+ * This runs before the page is sent, so it is a budget for somebody waiting on
39
+ * a tab to open. A server that is slow, asleep or gone simply does not name the
40
+ * card, which is exactly what happened before this existed.
41
+ */
42
+ const TIMEOUT_MS = 1200;
43
+
44
+ /**
45
+ * How long an answer is reused.
46
+ *
47
+ * A link shared into a busy channel is fetched once per crawler that unfurls
48
+ * it, and they arrive together. A minute is long enough to collapse that into
49
+ * one request and short enough that renaming a channel shows up while somebody
50
+ * is still looking at it.
51
+ */
52
+ const TTL_MS = 60_000;
53
+
54
+ const cache = new Map<string, { at: number; subject: RemoteSubject | null }>();
55
+
56
+ /**
57
+ * Hosts a server must never be asked to fetch.
58
+ *
59
+ * The address comes from the query string, so without this the page is a probe
60
+ * anyone can point at anything this machine can reach — a cloud metadata
61
+ * endpoint, a database admin port, a service on the loopback interface. The
62
+ * cost of being wrong here is not a bad preview, it is an open proxy.
63
+ */
64
+ function isPubliclyRoutable(hostname: string): boolean {
65
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
66
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return false;
67
+ // IPv6 loopback and the unique-local / link-local ranges.
68
+ if (host === "::1" || host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) {
69
+ return false;
70
+ }
71
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
72
+ if (v4) {
73
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
74
+ if (a === 10 || a === 127 || a === 0) return false;
75
+ if (a === 172 && b >= 16 && b <= 31) return false;
76
+ if (a === 192 && b === 168) return false;
77
+ if (a === 169 && b === 254) return false; // link-local, and AWS metadata
78
+ if (a >= 224) return false; // multicast and reserved
79
+ }
80
+ return true;
81
+ }
82
+
83
+ /** The share key a link carries, from /view/<key> or ?k=. */
84
+ export function keyFromLink(link: URL): string {
85
+ const viewed = /^\/view\/([^/]+)\/?$/.exec(link.pathname);
86
+ if (viewed?.[1]) return decodeURIComponent(viewed[1]);
87
+ return link.searchParams.get("k") ?? "";
88
+ }
89
+
90
+ /**
91
+ * Ask the linked server what it is called, and what the wanted channel is
92
+ * called on it.
93
+ *
94
+ * Returns null for every failure — an unreachable server, a refusal, a shape
95
+ * that is not what we expect, an address we will not fetch. The caller then
96
+ * renders the generic shell, which is what it did before.
97
+ */
98
+ export async function remoteSubject(
99
+ linkHref: string,
100
+ wantedChannel: string,
101
+ deps: { fetchImpl?: typeof fetch; now?: () => number } = {},
102
+ ): Promise<RemoteSubject | null> {
103
+ let link: URL;
104
+ try {
105
+ link = new URL(linkHref);
106
+ } catch {
107
+ return null;
108
+ }
109
+
110
+ // https only. An http link from an https page is blockable mixed content in
111
+ // the browser anyway, and fetching it here would be the one part of the
112
+ // journey that was not protected.
113
+ if (link.protocol !== "https:") return null;
114
+ if (!isPubliclyRoutable(link.hostname)) return null;
115
+
116
+ const key = keyFromLink(link);
117
+ const cacheKey = `${link.origin}|${key}|${wantedChannel}`;
118
+ const now = deps.now ?? Date.now;
119
+ const hit = cache.get(cacheKey);
120
+ if (hit && now() - hit.at < TTL_MS) return hit.subject;
121
+
122
+ const doFetch = deps.fetchImpl ?? fetch;
123
+ let subject: RemoteSubject | null = null;
124
+
125
+ try {
126
+ const asked = new URL("/api/streams", link.origin);
127
+ if (key !== "") asked.searchParams.set("k", key);
128
+ const answer = await doFetch(asked.toString(), {
129
+ headers: { accept: "application/json" },
130
+ signal: AbortSignal.timeout(TIMEOUT_MS),
131
+ });
132
+ if (answer.ok) {
133
+ const body = (await answer.json()) as StreamsAnswer;
134
+ const where = typeof body.server?.name === "string" ? body.server.name : "";
135
+ const channels = Array.isArray(body.channels) ? body.channels : [];
136
+ const found = wantedChannel === ""
137
+ ? undefined
138
+ : channels.find((one) => one.id === wantedChannel || one.name === wantedChannel);
139
+
140
+ if (found && typeof found.name === "string" && found.name !== "") {
141
+ subject = {
142
+ title: found.name,
143
+ where,
144
+ ...(typeof found.art === "string" && found.art !== "" ? { image: found.art } : {}),
145
+ ...(found.kind === "audio" || found.kind === "video" ? { kind: found.kind } : {}),
146
+ };
147
+ } else if (wantedChannel === "" && where !== "") {
148
+ // No channel asked for: the server itself is the subject.
149
+ subject = { title: where, where: "" };
150
+ }
151
+ }
152
+ } catch {
153
+ // Unreachable, refused, timed out, or not JSON. All the same answer.
154
+ subject = null;
155
+ }
156
+
157
+ // Failures are cached too, deliberately: a server that is down should not be
158
+ // re-asked by every crawler unfurling the same link in the same minute.
159
+ cache.set(cacheKey, { at: now(), subject });
160
+ return subject;
161
+ }
162
+
163
+ /** Testing seam: the cache outlives a request by design. */
164
+ export function __clearRemoteSubjectCache(): void {
165
+ cache.clear();
166
+ }