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.
- package/dist/ads.d.ts +11 -0
- package/dist/ads.js +73 -0
- package/dist/remote-subject.d.ts +42 -0
- package/dist/remote-subject.js +149 -0
- package/dist/server.js +54 -1
- package/package.json +1 -1
- package/src/ads.ts +84 -0
- package/src/remote-subject.ts +166 -0
- package/src/server.ts +59 -1
- package/web/dist/assets/{ar-BqLaLSMR.js → ar-NNtPSh0T.js} +1 -1
- package/web/dist/assets/{cs-CfoQJwKM.js → cs-CoDuxGFp.js} +1 -1
- package/web/dist/assets/{da-8MmH23fh.js → da-CIzoYCq-.js} +1 -1
- package/web/dist/assets/{de-Brry3OMh.js → de-D1hhSKwo.js} +1 -1
- package/web/dist/assets/{es-CGxJs7b8.js → es-DrrFYoGX.js} +1 -1
- package/web/dist/assets/{fi-BDAAZtto.js → fi-CzddkEge.js} +1 -1
- package/web/dist/assets/{fr-9sxYL0Qy.js → fr-CDWlgPE2.js} +1 -1
- package/web/dist/assets/{hi-Cc7ZsdL7.js → hi-D5DI3VOz.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Dx7qltzA.js → hls-3VKVEQE3-B23esRBf.js} +1 -1
- package/web/dist/assets/{hu-CvB9aIyN.js → hu-nFsc-Mn0.js} +1 -1
- package/web/dist/assets/{id-Bmrh06Hw.js → id-95FiAAmz.js} +1 -1
- package/web/dist/assets/index-C9HOvMut.js +3 -0
- package/web/dist/assets/{it-SbaxFUIP.js → it-CqQmls-8.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CqW3DT1n.js → mpegts-LO6RVLD6-BXCXHAVe.js} +1 -1
- package/web/dist/assets/{mpegts-BdwONu8t.js → mpegts-nqTu3Ia6.js} +1 -1
- package/web/dist/assets/{nl-CqimSo3B.js → nl-CyK7Lw-S.js} +1 -1
- package/web/dist/assets/{ru-wIfpMpU9.js → ru-CZ2Sf0im.js} +1 -1
- package/web/dist/assets/{sv-BAyIUHP5.js → sv-Wz7vgFh5.js} +1 -1
- package/web/dist/assets/{uk-D2jFJm6l.js → uk-Dp4ppXNY.js} +1 -1
- package/web/dist/assets/{vi-BdH3SLlR.js → vi-Bx_2Qd-6.js} +1 -1
- package/web/dist/assets/{zh-5xz5Agt-.js → zh-D0kAlzs8.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
- package/web/dist/assets/index-BQuoiKg9.js +0 -3
package/dist/ads.d.ts
ADDED
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
|
-
|
|
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
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
|
+
}
|