nixamp 0.55.0 → 0.55.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.
Files changed (34) hide show
  1. package/dist/label-routes.d.ts +3 -0
  2. package/dist/label-routes.js +3 -2
  3. package/dist/radio-pages.d.ts +14 -1
  4. package/dist/radio-pages.js +30 -3
  5. package/dist/server.js +29 -15
  6. package/dist/station-guide.d.ts +10 -1
  7. package/dist/station-guide.js +19 -2
  8. package/package.json +1 -1
  9. package/src/label-routes.ts +5 -2
  10. package/src/radio-pages.ts +28 -3
  11. package/src/server.ts +28 -15
  12. package/src/station-guide.ts +20 -2
  13. package/web/dist/assets/{ar-CCVdTxXd.js → ar-DDphLo2L.js} +1 -1
  14. package/web/dist/assets/{cs-DazinpO5.js → cs-CMKLJTnw.js} +1 -1
  15. package/web/dist/assets/{da-dANXD44J.js → da-CibDEkHw.js} +1 -1
  16. package/web/dist/assets/{de-BH5N_2Zt.js → de-D1BL2QsJ.js} +1 -1
  17. package/web/dist/assets/{es-lGWlpKlv.js → es-Cj9qr9jo.js} +1 -1
  18. package/web/dist/assets/{fi-CIIF4ItN.js → fi-DqCO5i_7.js} +1 -1
  19. package/web/dist/assets/{fr-s0WjlDkZ.js → fr-DUk14AWf.js} +1 -1
  20. package/web/dist/assets/{hi-Dwrx_fdX.js → hi-U_jzSO9-.js} +1 -1
  21. package/web/dist/assets/{hu-B7yw75er.js → hu-B28enhWa.js} +1 -1
  22. package/web/dist/assets/{id-DNdkH2kw.js → id-DlGfGd_6.js} +1 -1
  23. package/web/dist/assets/{it-C9858eN5.js → it-D3HMQpf5.js} +1 -1
  24. package/web/dist/assets/{main-Coq6MRdf.js → main-D0eqtyhi.js} +2 -2
  25. package/web/dist/assets/{nl-BeH2Fgue.js → nl-i1Y9R3xr.js} +1 -1
  26. package/web/dist/assets/{ru-DZ2nMkFG.js → ru-DjRg1y2P.js} +1 -1
  27. package/web/dist/assets/{station-DGgba3hs.js → station-DxBlD6Yl.js} +1 -1
  28. package/web/dist/assets/{sv-CuTZodhi.js → sv-Dsj-93a2.js} +1 -1
  29. package/web/dist/assets/{uk-Bt534Jo1.js → uk-COWBdTZb.js} +1 -1
  30. package/web/dist/assets/{vi-Ci0kYdXP.js → vi-CgEMtDCA.js} +1 -1
  31. package/web/dist/assets/{zh-C8tiLpKW.js → zh-CBa5mJ__.js} +1 -1
  32. package/web/dist/index.html +1 -1
  33. package/web/dist/station.html +1 -1
  34. package/web/dist/sw.js +20 -20
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { type AiLabel } from "./ai-label.ts";
3
3
  import { type GrantVerifier } from "./music-store.ts";
4
+ import { type GuideAds } from "./station-guide.ts";
4
5
  /** Whether this path is the label's public face (an MP3 or the zip still asks for its grant). */
5
6
  export declare function isPublicLabelPath(path: string): boolean;
6
7
  /** nixamp.com's page that sells this: a song, an album or everything by the artist. */
@@ -13,6 +14,8 @@ export interface LabelRoutesContext {
13
14
  /** nixamp.com, where the store is, and this server's public address (for Buy links). */
14
15
  site?: string;
15
16
  publicBase?: string;
17
+ /** The one text_link ad /label carries (station-guide.ts withPageAd); none when unset. */
18
+ ads?: GuideAds;
16
19
  json: (response: ServerResponse, status: number, body: unknown) => void;
17
20
  readBody: (request: IncomingMessage) => Promise<string>;
18
21
  }
@@ -12,6 +12,7 @@ import { ROSTER } from "./ai-label.js";
12
12
  import { covers, PRICES } from "./music-store.js";
13
13
  import { writeZip, zipSize } from "./zip-stream.js";
14
14
  import { StationError } from "./stations.js";
15
+ import { adCsp, withPageAd } from "./station-guide.js";
15
16
  // Listening is free on the live stations (with their breaks); the songs as
16
17
  // files are sold, so only the words and the cover are public here, and an
17
18
  // MP3 or a zip needs a grant from nixamp.com's store (music-store.ts).
@@ -88,8 +89,8 @@ export async function handleLabelRoutes(request, response, path, ctx) {
88
89
  }
89
90
  if (path === "/label") {
90
91
  const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase } : undefined;
91
- const page = renderLabel(label.publicView(), shop);
92
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
92
+ const page = withPageAd(renderLabel(label.publicView(), shop), ctx.ads ?? {});
93
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": adCsp(LABEL_CSP, ctx.ads ?? {}), "cache-control": "public, max-age=300" });
93
94
  response.end(method === "HEAD" ? undefined : page);
94
95
  return true;
95
96
  }
@@ -6,6 +6,19 @@ export declare const RADIO_STATION: RegExp;
6
6
  export declare const RADIO_ARTIST: RegExp;
7
7
  export declare const RADIO_ALBUM: RegExp;
8
8
  export declare function isRadioPath(path: string): boolean;
9
+ /**
10
+ * Buy, through this server: a 302 to nixamp.com's store page for it, so the
11
+ * link on a /radio page does not carry the machine the music is on.
12
+ */
13
+ export declare const RADIO_BUY = "/radio/buy";
14
+ /** /radio/buy?kind=&artist=&album=&n=, checked: the label's keys and nothing else. */
15
+ export declare function radioBuyQuery(query: URLSearchParams): {
16
+ kind: "song" | "album" | "artist";
17
+ artist: string;
18
+ album: string;
19
+ n: number;
20
+ } | null;
21
+ export declare function radioBuyLink(kind: "song" | "album" | "artist", artist: string, album?: string, n?: number): string;
9
22
  /**
10
23
  * Public, listed stations by name. Oldest first, so a name stays with the
11
24
  * station that had it; a second "Jazz" is jazz-2.
@@ -115,7 +128,7 @@ export declare function rewriteResponse(response: ServerResponse, rewrite: (text
115
128
  export declare function radioRewriter(station: {
116
129
  id: string;
117
130
  listenToken: string;
118
- }, slug: string): (text: string) => string;
131
+ }, slug: string, buyFrom?: string): (text: string) => string;
119
132
  /** The live.streaming header every /radio page carries, so a listener can get anywhere from one. */
120
133
  export declare const NAV_STYLE = ".radio-nav{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;max-width:960px;margin:0 auto;padding:14px 16px;font:600 15px/1.4 system-ui,-apple-system,\"Segoe UI\",sans-serif}\n.radio-nav a{color:inherit;text-decoration:none;opacity:.8}.radio-nav a:hover,.radio-nav a[aria-current=page]{opacity:1;text-decoration:underline}\n.radio-nav .brand{font-weight:800;opacity:1;margin-right:auto}";
121
134
  export declare function radioNav(home: string, current?: "live" | "radio" | "label" | "vod" | ""): string;
@@ -26,9 +26,33 @@ export const RADIO_STATION = /^\/radio\/([a-z0-9-]{1,60})\/?$/;
26
26
  export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
27
27
  export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
28
28
  export function isRadioPath(path) {
29
- return path === "/radio" || path === "/radio/" || RADIO_SUB.test(path) || RADIO_STATION.test(path) || RADIO_ARTIST.test(path) || RADIO_ALBUM.test(path);
29
+ return path === "/radio" || path === "/radio/" || path === RADIO_BUY || RADIO_SUB.test(path) || RADIO_STATION.test(path) || RADIO_ARTIST.test(path) || RADIO_ALBUM.test(path);
30
+ }
31
+ const RESERVED = new Set(["artist", "album", "artists", "albums", "buy"]);
32
+ /**
33
+ * Buy, through this server: a 302 to nixamp.com's store page for it, so the
34
+ * link on a /radio page does not carry the machine the music is on.
35
+ */
36
+ export const RADIO_BUY = "/radio/buy";
37
+ /** /radio/buy?kind=&artist=&album=&n=, checked: the label's keys and nothing else. */
38
+ export function radioBuyQuery(query) {
39
+ const kind = query.get("kind");
40
+ const artist = query.get("artist") ?? "";
41
+ const album = query.get("album") ?? "";
42
+ const n = Number(query.get("n") ?? 0);
43
+ if (kind !== "song" && kind !== "album" && kind !== "artist")
44
+ return null;
45
+ if (!/^[a-f0-9]{10}$/.test(artist))
46
+ return null;
47
+ if (kind !== "artist" && !/^[a-f0-9]{8}$/.test(album))
48
+ return null;
49
+ if (kind === "song" && !(Number.isInteger(n) && n >= 1 && n <= 999))
50
+ return null;
51
+ return { kind, artist, album: kind === "artist" ? "" : album, n: kind === "song" ? n : 0 };
52
+ }
53
+ export function radioBuyLink(kind, artist, album = "", n = 0) {
54
+ return `${RADIO_BUY}?${new URLSearchParams({ kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) }).toString()}`;
30
55
  }
31
- const RESERVED = new Set(["artist", "album", "artists", "albums"]);
32
56
  /**
33
57
  * Public, listed stations by name. Oldest first, so a name stays with the
34
58
  * station that had it; a second "Jazz" is jazz-2.
@@ -180,11 +204,14 @@ export function rewriteResponse(response, rewrite) {
180
204
  response.end = (chunk, ...rest) => end(swap(chunk), ...rest);
181
205
  }
182
206
  /** The station's addresses and token, as /radio/<name> says them instead. */
183
- export function radioRewriter(station, slug) {
207
+ export function radioRewriter(station, slug, buyFrom = "") {
184
208
  const token = station.listenToken;
185
209
  const encoded = encodeURIComponent(token);
186
210
  return (text) => {
187
211
  let out = text.split(`/api/v1/stations/${station.id}/`).join(`/radio/${slug}/`);
212
+ // The store's links name this server; /radio/buy sends there without saying so.
213
+ if (buyFrom)
214
+ out = out.split(buyFrom).join(`${RADIO_BUY}?`);
188
215
  for (const one of new Set([encoded, token])) {
189
216
  out = out.split(`?t=${one}&`).join("?").split(`&t=${one}`).join("").split(`?t=${one}`).join("").split(one).join("");
190
217
  }
package/dist/server.js CHANGED
@@ -77,8 +77,8 @@ import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./gui
77
77
  import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.js";
78
78
  import { withPitFooter } from "./pit-footer.js";
79
79
  import { handleFollowPage } from "./follow-page.js";
80
- import { asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, radioNav, radioPeers, radioRewriter, radioStations, renderAlbum, renderArtist, renderDirectory, renderMissing, rewriteResponse, } from "./radio-pages.js";
81
- import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
80
+ import { asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, RADIO_BUY, radioBuyLink, radioBuyQuery, radioNav, radioPeers, radioRewriter, radioStations, renderAlbum, renderArtist, renderDirectory, renderMissing, rewriteResponse, } from "./radio-pages.js";
81
+ import { adCsp, guideAds, guideCsp, withPageAd, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
82
82
  import { StationRunner } from "./station-runner.js";
83
83
  import { StationError, StationStore, channelOf, stationOfChannel } from "./stations.js";
84
84
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv } from "./station-metering.js";
@@ -1590,7 +1590,8 @@ export function createHandler(engine, options) {
1590
1590
  query.delete("t");
1591
1591
  query.set("t", station.listenToken);
1592
1592
  request.url = `/api/v1/stations/${station.id}/${sub[2]}?${query.toString()}`;
1593
- rewriteResponse(response, radioRewriter(station, sub[1]));
1593
+ const here = options.stations.publicBase?.() ?? "";
1594
+ rewriteResponse(response, radioRewriter(station, sub[1], /^https:\/\//.test(here) ? `${storeSite}/buy?server=${encodeURIComponent(here)}&` : ""));
1594
1595
  return handle(request, response);
1595
1596
  }
1596
1597
  }
@@ -2529,7 +2530,7 @@ export function createHandler(engine, options) {
2529
2530
  });
2530
2531
  // Behind a guide name (live.streaming) the page asks its own origin, so no server address shows in it.
2531
2532
  const apiBase = guideHosts().has(requestHost) ? "" : options.stations.publicBase?.() ?? "";
2532
- const page = renderGuide([...rooms, ...entries, ...named], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "");
2533
+ const page = renderGuide([...rooms, ...entries, ...named], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "", path === "/");
2533
2534
  response.writeHead(200, {
2534
2535
  "content-type": "text/html; charset=utf-8",
2535
2536
  "cache-control": "no-cache",
@@ -2546,7 +2547,7 @@ export function createHandler(engine, options) {
2546
2547
  // stream address>. The built page plays it in @profullstack/player, as a
2547
2548
  // film or as radio; nothing about the room passes through here.
2548
2549
  if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
2549
- const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
2550
+ const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
2550
2551
  if (!built) {
2551
2552
  json(response, 404, { error: "this server was started without its web pages" });
2552
2553
  return;
@@ -2554,7 +2555,7 @@ export function createHandler(engine, options) {
2554
2555
  response.writeHead(200, {
2555
2556
  "content-type": "text/html; charset=utf-8",
2556
2557
  "cache-control": "no-cache",
2557
- "content-security-policy": WATCH_APP_CSP,
2558
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
2558
2559
  "referrer-policy": "no-referrer",
2559
2560
  "x-content-type-options": "nosniff",
2560
2561
  });
@@ -2581,11 +2582,11 @@ export function createHandler(engine, options) {
2581
2582
  response.writeHead(vodPath[1] && !one ? 404 : 200, {
2582
2583
  "content-type": "text/html; charset=utf-8",
2583
2584
  "cache-control": "no-cache",
2584
- "content-security-policy": WATCH_APP_CSP,
2585
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
2585
2586
  "referrer-policy": "no-referrer",
2586
2587
  "x-content-type-options": "nosniff",
2587
2588
  });
2588
- response.end(request.method === "HEAD" ? undefined : built.replace(/<title>[^<]*<\/title>/, head));
2589
+ response.end(request.method === "HEAD" ? undefined : withPageAd(built.replace(/<title>[^<]*<\/title>/, head), guideAds()));
2589
2590
  return;
2590
2591
  }
2591
2592
  // /radio: public, listed stations under readable names, and the artists and
@@ -2598,20 +2599,30 @@ export function createHandler(engine, options) {
2598
2599
  const base = radioBaseFor(request, publicBase);
2599
2600
  const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide");
2600
2601
  const send = (status, page, csp = RADIO_CSP) => {
2602
+ const ads = guideAds();
2603
+ page = withPageAd(page, ads);
2601
2604
  response.writeHead(status, {
2602
2605
  "content-type": "text/html; charset=utf-8",
2603
2606
  "cache-control": status === 200 ? "public, max-age=60" : "no-cache",
2604
- "content-security-policy": csp,
2607
+ "content-security-policy": adCsp(csp, ads),
2605
2608
  "referrer-policy": "strict-origin-when-cross-origin",
2606
2609
  "x-content-type-options": "nosniff",
2607
2610
  });
2608
2611
  response.end(request.method === "HEAD" ? undefined : page);
2609
2612
  };
2610
2613
  const shop = stations.label && /^https:\/\//.test(publicBase)
2611
- ? { site: storeSite, buy: (kind, artist, album = "", n = 0) => buyLink(storeSite, publicBase, kind, artist, album, n), prices: PRICES }
2614
+ ? { site: storeSite, buy: radioBuyLink, prices: PRICES }
2612
2615
  : null;
2613
2616
  const peers = stations.peers ? radioPeers(named.keys(), await stations.peers.entries()) : new Map();
2614
2617
  const stationNames = new Map([...named].map(([slug, one]) => [slug, one.name]));
2618
+ if (path === RADIO_BUY) {
2619
+ const item = radioBuyQuery(url.searchParams);
2620
+ if (!item || !shop)
2621
+ return send(404, renderMissing("thing for sale", base, nav));
2622
+ response.writeHead(302, { location: buyLink(storeSite, publicBase, item.kind, item.artist, item.album, item.n), "cache-control": "no-store", "referrer-policy": "no-referrer" });
2623
+ response.end();
2624
+ return;
2625
+ }
2615
2626
  if (path === "/radio" || path === "/radio/") {
2616
2627
  const encrypted = request.socket.encrypted === true;
2617
2628
  const entries = guideEntries({
@@ -2670,12 +2681,14 @@ export function createHandler(engine, options) {
2670
2681
  return send(404, renderMissing("station", base, nav));
2671
2682
  }
2672
2683
  const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html"), "utf8") : null;
2673
- const page = asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav);
2684
+ // The built page carries the one ad; the inline fallback stays as it is.
2685
+ const ads = built ? guideAds() : {};
2686
+ const page = withPageAd(asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav), ads);
2674
2687
  response.writeHead(200, {
2675
2688
  "content-type": "text/html; charset=utf-8",
2676
2689
  "cache-control": "no-cache",
2677
2690
  // The inline page names its styles by hash; the header's is one more.
2678
- "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
2691
+ "content-security-policy": built ? adCsp(STATION_APP_CSP, ads) : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
2679
2692
  "referrer-policy": "strict-origin-when-cross-origin",
2680
2693
  "x-content-type-options": "nosniff",
2681
2694
  });
@@ -2733,11 +2746,12 @@ export function createHandler(engine, options) {
2733
2746
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
2734
2747
  // The built page plays in @profullstack/player; the inline one is for a
2735
2748
  // server started without the web build.
2736
- const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html")) : null;
2749
+ const ads = guideAds();
2750
+ const built = web !== null && isFile(join(web, "station.html")) ? withPageAd(readFileSync(join(web, "station.html"), "utf8"), ads) : null;
2737
2751
  response.writeHead(200, {
2738
2752
  "content-type": "text/html; charset=utf-8",
2739
2753
  "cache-control": "no-cache",
2740
- "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP,
2754
+ "content-security-policy": built ? adCsp(STATION_APP_CSP, ads) : STATION_PAGE_CSP,
2741
2755
  "referrer-policy": "no-referrer",
2742
2756
  "x-content-type-options": "nosniff",
2743
2757
  });
@@ -2795,7 +2809,7 @@ export function createHandler(engine, options) {
2795
2809
  const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
2796
2810
  if (await handleLabelRoutes(request, response, path, {
2797
2811
  label: options.stations.label, canControl: control.allowed, json, readBody,
2798
- grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "",
2812
+ grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "", ads: guideAds(),
2799
2813
  }))
2800
2814
  return;
2801
2815
  }
@@ -29,7 +29,7 @@ export interface GuideEntry {
29
29
  * gateway that cannot run its script against this server (the Moshpit's
30
30
  * /n/<name>), and for a crawler. Everything from a station is escaped.
31
31
  */
32
- export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds, labelHref?: string): string;
32
+ export declare function renderGuide(entries: GuideEntry[], apiBase?: string, ads?: GuideAds, labelHref?: string, home?: boolean): string;
33
33
  export declare const GUIDE_HTML: string;
34
34
  /**
35
35
  * The guide's policy. With crawlproof configured (guideAds below) its tracker
@@ -38,6 +38,15 @@ export declare const GUIDE_HTML: string;
38
38
  */
39
39
  export declare function guideCsp(ads?: GuideAds): string;
40
40
  export declare const GUIDE_CSP: string;
41
+ /**
42
+ * The one ad an inner page carries -- a station, /radio, an artist, an album,
43
+ * /label, /vod, /watch -- and never a home page: a single text_link line at
44
+ * the top of its content, sized by attributes so a page whose CSP hashes its
45
+ * styles needs no new one. The page as it was without a slot.
46
+ */
47
+ export declare function withPageAd(html: string, ads: GuideAds): string;
48
+ /** A page's CSP with room for that ad's frame. */
49
+ export declare function adCsp(csp: string, ads: GuideAds): string;
41
50
  /** crawlproof analytics and an ad on the guide: a project id (tracker data-site) and an ad slot. */
42
51
  export interface GuideAds {
43
52
  site?: string;
@@ -151,7 +151,7 @@ const GROUPS = ["rooms", "vod", "ai", "shared", "radio", "tv"];
151
151
  * gateway that cannot run its script against this server (the Moshpit's
152
152
  * /n/<name>), and for a crawler. Everything from a station is escaped.
153
153
  */
154
- export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "") {
154
+ export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "", home = false) {
155
155
  const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
156
156
  const cards = (group) => entries
157
157
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
@@ -166,7 +166,8 @@ export function renderGuide(entries, apiBase = "", ads = {}, labelHref = "") {
166
166
  }
167
167
  if (ads.site)
168
168
  page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
169
- if (ads.slot) {
169
+ // A home page carries no ad (live.streaming's / is this guide); nixamp.com/guide is an inner page.
170
+ if (ads.slot && !home) {
170
171
  // One line by default: a 300x250 box above the stations pushed the guide itself off the first screen.
171
172
  const format = ads.format ?? "text_link";
172
173
  const slot = ads.slot;
@@ -249,6 +250,22 @@ export function guideCsp(ads = {}) {
249
250
  ].join("; ");
250
251
  }
251
252
  export const GUIDE_CSP = guideCsp();
253
+ /**
254
+ * The one ad an inner page carries -- a station, /radio, an artist, an album,
255
+ * /label, /vod, /watch -- and never a home page: a single text_link line at
256
+ * the top of its content, sized by attributes so a page whose CSP hashes its
257
+ * styles needs no new one. The page as it was without a slot.
258
+ */
259
+ export function withPageAd(html, ads) {
260
+ if (!ads.slot)
261
+ return html;
262
+ const unit = `<div class="ad-unit ad-text_link"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(ads.slot)}&amp;format=text_link" title="Advertisement" width="100%" height="40" frameborder="0" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>`;
263
+ return html.replace(/<main\b[^>]*>/, (main) => `${main}\n${unit}`);
264
+ }
265
+ /** A page's CSP with room for that ad's frame. */
266
+ export function adCsp(csp, ads) {
267
+ return ads.slot ? `${csp}; frame-src 'self' ${CRAWLPROOF}` : csp;
268
+ }
252
269
  const AD_ID = /^[A-Za-z0-9-]{1,64}$/;
253
270
  /** A setting as the guide may use it: ids that are ids, a format it knows, or nothing. */
254
271
  export function cleanGuideAds(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.55.0",
3
+ "version": "0.55.2",
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",
@@ -13,6 +13,7 @@ import { ROSTER, type AiLabel, type PublicArtist } from "./ai-label.ts";
13
13
  import { covers, PRICES, type GrantVerifier } from "./music-store.ts";
14
14
  import { writeZip, zipSize } from "./zip-stream.ts";
15
15
  import { StationError } from "./stations.ts";
16
+ import { adCsp, withPageAd, type GuideAds } from "./station-guide.ts";
16
17
 
17
18
  // Listening is free on the live stations (with their breaks); the songs as
18
19
  // files are sold, so only the words and the cover are public here, and an
@@ -38,6 +39,8 @@ export interface LabelRoutesContext {
38
39
  /** nixamp.com, where the store is, and this server's public address (for Buy links). */
39
40
  site?: string;
40
41
  publicBase?: string;
42
+ /** The one text_link ad /label carries (station-guide.ts withPageAd); none when unset. */
43
+ ads?: GuideAds;
41
44
  json: (response: ServerResponse, status: number, body: unknown) => void;
42
45
  readBody: (request: IncomingMessage) => Promise<string>;
43
46
  }
@@ -106,8 +109,8 @@ export async function handleLabelRoutes(
106
109
  }
107
110
  if (path === "/label") {
108
111
  const shop = ctx.grants && ctx.site && /^https:\/\//.test(ctx.publicBase ?? "") ? { site: ctx.site, server: ctx.publicBase! } : undefined;
109
- const page = renderLabel(label.publicView(), shop);
110
- response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": LABEL_CSP, "cache-control": "public, max-age=300" });
112
+ const page = withPageAd(renderLabel(label.publicView(), shop), ctx.ads ?? {});
113
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": adCsp(LABEL_CSP, ctx.ads ?? {}), "cache-control": "public, max-age=300" });
111
114
  response.end(method === "HEAD" ? undefined : page);
112
115
  return true;
113
116
  }
@@ -30,10 +30,33 @@ export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
30
30
  export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
31
31
 
32
32
  export function isRadioPath(path: string): boolean {
33
- return path === "/radio" || path === "/radio/" || RADIO_SUB.test(path) || RADIO_STATION.test(path) || RADIO_ARTIST.test(path) || RADIO_ALBUM.test(path);
33
+ return path === "/radio" || path === "/radio/" || path === RADIO_BUY || RADIO_SUB.test(path) || RADIO_STATION.test(path) || RADIO_ARTIST.test(path) || RADIO_ALBUM.test(path);
34
34
  }
35
35
 
36
- const RESERVED = new Set(["artist", "album", "artists", "albums"]);
36
+ const RESERVED = new Set(["artist", "album", "artists", "albums", "buy"]);
37
+
38
+ /**
39
+ * Buy, through this server: a 302 to nixamp.com's store page for it, so the
40
+ * link on a /radio page does not carry the machine the music is on.
41
+ */
42
+ export const RADIO_BUY = "/radio/buy";
43
+
44
+ /** /radio/buy?kind=&artist=&album=&n=, checked: the label's keys and nothing else. */
45
+ export function radioBuyQuery(query: URLSearchParams): { kind: "song" | "album" | "artist"; artist: string; album: string; n: number } | null {
46
+ const kind = query.get("kind");
47
+ const artist = query.get("artist") ?? "";
48
+ const album = query.get("album") ?? "";
49
+ const n = Number(query.get("n") ?? 0);
50
+ if (kind !== "song" && kind !== "album" && kind !== "artist") return null;
51
+ if (!/^[a-f0-9]{10}$/.test(artist)) return null;
52
+ if (kind !== "artist" && !/^[a-f0-9]{8}$/.test(album)) return null;
53
+ if (kind === "song" && !(Number.isInteger(n) && n >= 1 && n <= 999)) return null;
54
+ return { kind, artist, album: kind === "artist" ? "" : album, n: kind === "song" ? n : 0 };
55
+ }
56
+
57
+ export function radioBuyLink(kind: "song" | "album" | "artist", artist: string, album = "", n = 0): string {
58
+ return `${RADIO_BUY}?${new URLSearchParams({ kind, artist, ...(kind !== "artist" ? { album } : {}), ...(kind === "song" ? { n: String(n) } : {}) }).toString()}`;
59
+ }
37
60
 
38
61
  /**
39
62
  * Public, listed stations by name. Oldest first, so a name stays with the
@@ -224,11 +247,13 @@ export function rewriteResponse(response: ServerResponse, rewrite: (text: string
224
247
  }
225
248
 
226
249
  /** The station's addresses and token, as /radio/<name> says them instead. */
227
- export function radioRewriter(station: { id: string; listenToken: string }, slug: string): (text: string) => string {
250
+ export function radioRewriter(station: { id: string; listenToken: string }, slug: string, buyFrom = ""): (text: string) => string {
228
251
  const token = station.listenToken;
229
252
  const encoded = encodeURIComponent(token);
230
253
  return (text) => {
231
254
  let out = text.split(`/api/v1/stations/${station.id}/`).join(`/radio/${slug}/`);
255
+ // The store's links name this server; /radio/buy sends there without saying so.
256
+ if (buyFrom) out = out.split(buyFrom).join(`${RADIO_BUY}?`);
232
257
  for (const one of new Set([encoded, token])) {
233
258
  out = out.split(`?t=${one}&`).join("?").split(`&t=${one}`).join("").split(`?t=${one}`).join("").split(one).join("");
234
259
  }
package/src/server.ts CHANGED
@@ -95,10 +95,10 @@ import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vod
95
95
  import { withPitFooter } from "./pit-footer.ts";
96
96
  import { handleFollowPage } from "./follow-page.ts";
97
97
  import {
98
- asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, radioNav, radioPeers, radioRewriter,
98
+ asRadioPage, buildCatalogue, isRadioPath, NAV_STYLE, RADIO_ALBUM, RADIO_ARTIST, RADIO_CSP, RADIO_STATION, RADIO_SUB, RADIO_BUY, radioBuyLink, radioBuyQuery, radioNav, radioPeers, radioRewriter,
99
99
  radioStations, renderAlbum, renderArtist, renderDirectory, renderMissing, rewriteResponse, type PeerRadio, type RadioCatalogue,
100
100
  } from "./radio-pages.ts";
101
- import { guideAds, guideCsp, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
101
+ import { adCsp, guideAds, guideCsp, withPageAd, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
102
102
  import { StationRunner } from "./station-runner.ts";
103
103
  import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
104
104
  import { STATION_CONTROL_LIMIT, StationUsage, limitsFromEnv, type Limits } from "./station-metering.ts";
@@ -2139,7 +2139,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2139
2139
  query.delete("t");
2140
2140
  query.set("t", station!.listenToken);
2141
2141
  request.url = `/api/v1/stations/${station!.id}/${sub[2]}?${query.toString()}`;
2142
- rewriteResponse(response, radioRewriter(station!, sub[1]!));
2142
+ const here = options.stations.publicBase?.() ?? "";
2143
+ rewriteResponse(response, radioRewriter(station!, sub[1]!, /^https:\/\//.test(here) ? `${storeSite}/buy?server=${encodeURIComponent(here)}&` : ""));
2143
2144
  return handle(request, response);
2144
2145
  }
2145
2146
  }
@@ -3056,7 +3057,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3056
3057
  });
3057
3058
  // Behind a guide name (live.streaming) the page asks its own origin, so no server address shows in it.
3058
3059
  const apiBase = guideHosts().has(requestHost) ? "" : options.stations!.publicBase?.() ?? "";
3059
- const page = renderGuide([...rooms, ...entries, ...named] as GuideEntry[], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "");
3060
+ const page = renderGuide([...rooms, ...entries, ...named] as GuideEntry[], apiBase, ads, label && label.publicView().genres.length > 0 ? "/label" : "", path === "/");
3060
3061
  response.writeHead(200, {
3061
3062
  "content-type": "text/html; charset=utf-8",
3062
3063
  "cache-control": "no-cache",
@@ -3074,7 +3075,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3074
3075
  // stream address>. The built page plays it in @profullstack/player, as a
3075
3076
  // film or as radio; nothing about the room passes through here.
3076
3077
  if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
3077
- const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html")) : null;
3078
+ const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
3078
3079
  if (!built) {
3079
3080
  json(response, 404, { error: "this server was started without its web pages" });
3080
3081
  return;
@@ -3082,7 +3083,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3082
3083
  response.writeHead(200, {
3083
3084
  "content-type": "text/html; charset=utf-8",
3084
3085
  "cache-control": "no-cache",
3085
- "content-security-policy": WATCH_APP_CSP,
3086
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
3086
3087
  "referrer-policy": "no-referrer",
3087
3088
  "x-content-type-options": "nosniff",
3088
3089
  });
@@ -3110,11 +3111,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3110
3111
  response.writeHead(vodPath[1] && !one ? 404 : 200, {
3111
3112
  "content-type": "text/html; charset=utf-8",
3112
3113
  "cache-control": "no-cache",
3113
- "content-security-policy": WATCH_APP_CSP,
3114
+ "content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
3114
3115
  "referrer-policy": "no-referrer",
3115
3116
  "x-content-type-options": "nosniff",
3116
3117
  });
3117
- response.end(request.method === "HEAD" ? undefined : built.replace(/<title>[^<]*<\/title>/, head));
3118
+ response.end(request.method === "HEAD" ? undefined : withPageAd(built.replace(/<title>[^<]*<\/title>/, head), guideAds()));
3118
3119
  return;
3119
3120
  }
3120
3121
 
@@ -3128,20 +3129,29 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3128
3129
  const base = radioBaseFor(request, publicBase);
3129
3130
  const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide");
3130
3131
  const send = (status: number, page: string, csp = RADIO_CSP): void => {
3132
+ const ads = guideAds();
3133
+ page = withPageAd(page, ads);
3131
3134
  response.writeHead(status, {
3132
3135
  "content-type": "text/html; charset=utf-8",
3133
3136
  "cache-control": status === 200 ? "public, max-age=60" : "no-cache",
3134
- "content-security-policy": csp,
3137
+ "content-security-policy": adCsp(csp, ads),
3135
3138
  "referrer-policy": "strict-origin-when-cross-origin",
3136
3139
  "x-content-type-options": "nosniff",
3137
3140
  });
3138
3141
  response.end(request.method === "HEAD" ? undefined : page);
3139
3142
  };
3140
3143
  const shop = stations.label && /^https:\/\//.test(publicBase)
3141
- ? { site: storeSite, buy: (kind: "song" | "album" | "artist", artist: string, album = "", n = 0) => buyLink(storeSite, publicBase, kind, artist, album, n), prices: PRICES }
3144
+ ? { site: storeSite, buy: radioBuyLink, prices: PRICES }
3142
3145
  : null;
3143
3146
  const peers = stations.peers ? radioPeers(named.keys(), await stations.peers.entries()) : new Map<string, PeerRadio>();
3144
3147
  const stationNames = new Map([...named].map(([slug, one]) => [slug, one.name]));
3148
+ if (path === RADIO_BUY) {
3149
+ const item = radioBuyQuery(url.searchParams);
3150
+ if (!item || !shop) return send(404, renderMissing("thing for sale", base, nav));
3151
+ response.writeHead(302, { location: buyLink(storeSite, publicBase, item.kind, item.artist, item.album, item.n), "cache-control": "no-store", "referrer-policy": "no-referrer" });
3152
+ response.end();
3153
+ return;
3154
+ }
3145
3155
  if (path === "/radio" || path === "/radio/") {
3146
3156
  const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
3147
3157
  const entries = guideEntries({
@@ -3198,12 +3208,14 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3198
3208
  return send(404, renderMissing("station", base, nav));
3199
3209
  }
3200
3210
  const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html"), "utf8") : null;
3201
- const page = asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav);
3211
+ // The built page carries the one ad; the inline fallback stays as it is.
3212
+ const ads = built ? guideAds() : {};
3213
+ const page = withPageAd(asRadioPage(built ?? STATION_PAGE_HTML, station, slug, base, nav), ads);
3202
3214
  response.writeHead(200, {
3203
3215
  "content-type": "text/html; charset=utf-8",
3204
3216
  "cache-control": "no-cache",
3205
3217
  // The inline page names its styles by hash; the header's is one more.
3206
- "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
3218
+ "content-security-policy": built ? adCsp(STATION_APP_CSP, ads) : STATION_PAGE_CSP.replace("style-src ", `style-src 'sha256-${sha("sha256").update(NAV_STYLE).digest("base64")}' `),
3207
3219
  "referrer-policy": "strict-origin-when-cross-origin",
3208
3220
  "x-content-type-options": "nosniff",
3209
3221
  });
@@ -3263,11 +3275,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3263
3275
  if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
3264
3276
  // The built page plays in @profullstack/player; the inline one is for a
3265
3277
  // server started without the web build.
3266
- const built = web !== null && isFile(join(web, "station.html")) ? readFileSync(join(web, "station.html")) : null;
3278
+ const ads = guideAds();
3279
+ const built = web !== null && isFile(join(web, "station.html")) ? withPageAd(readFileSync(join(web, "station.html"), "utf8"), ads) : null;
3267
3280
  response.writeHead(200, {
3268
3281
  "content-type": "text/html; charset=utf-8",
3269
3282
  "cache-control": "no-cache",
3270
- "content-security-policy": built ? STATION_APP_CSP : STATION_PAGE_CSP,
3283
+ "content-security-policy": built ? adCsp(STATION_APP_CSP, ads) : STATION_PAGE_CSP,
3271
3284
  "referrer-policy": "no-referrer",
3272
3285
  "x-content-type-options": "nosniff",
3273
3286
  });
@@ -3328,7 +3341,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3328
3341
  const control = isPublicLabelPath(path) ? { allowed: false } : await controlOf();
3329
3342
  if (await handleLabelRoutes(request, response, path, {
3330
3343
  label: options.stations.label, canControl: control.allowed, json, readBody,
3331
- grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "",
3344
+ grants: storeGrants, site: storeSite, publicBase: options.stations.publicBase?.() ?? "", ads: guideAds(),
3332
3345
  })) return;
3333
3346
  }
3334
3347
 
@@ -173,7 +173,7 @@ const GROUPS = ["rooms", "vod", "ai", "shared", "radio", "tv"] as const;
173
173
  * gateway that cannot run its script against this server (the Moshpit's
174
174
  * /n/<name>), and for a crawler. Everything from a station is escaped.
175
175
  */
176
- export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds = {}, labelHref = ""): string {
176
+ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds = {}, labelHref = "", home = false): string {
177
177
  const groupOf = (one: GuideEntry): string => one.group ?? (one.kind === "video" ? "tv" : "radio");
178
178
  const cards = (group: string): string => entries
179
179
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
@@ -187,7 +187,8 @@ export function renderGuide(entries: GuideEntry[], apiBase = "", ads: GuideAds =
187
187
  .replace(`<div id="${group}" class="grid" aria-live="polite"></div>`, `<div id="${group}" class="grid" aria-live="polite">${made}</div>`);
188
188
  }
189
189
  if (ads.site) page = page.replace("</head>", `<script data-site="${escape(ads.site)}" src="${CRAWLPROOF}/stats.js" async></script>\n</head>`);
190
- if (ads.slot) {
190
+ // A home page carries no ad (live.streaming's / is this guide); nixamp.com/guide is an inner page.
191
+ if (ads.slot && !home) {
191
192
  // One line by default: a 300x250 box above the stations pushed the guide itself off the first screen.
192
193
  const format = ads.format ?? "text_link";
193
194
  const slot = ads.slot;
@@ -274,6 +275,23 @@ export function guideCsp(ads: GuideAds = {}): string {
274
275
 
275
276
  export const GUIDE_CSP = guideCsp();
276
277
 
278
+ /**
279
+ * The one ad an inner page carries -- a station, /radio, an artist, an album,
280
+ * /label, /vod, /watch -- and never a home page: a single text_link line at
281
+ * the top of its content, sized by attributes so a page whose CSP hashes its
282
+ * styles needs no new one. The page as it was without a slot.
283
+ */
284
+ export function withPageAd(html: string, ads: GuideAds): string {
285
+ if (!ads.slot) return html;
286
+ const unit = `<div class="ad-unit ad-text_link"><iframe src="${CRAWLPROOF}/api/ads/frame?slot=${encodeURIComponent(ads.slot)}&amp;format=text_link" title="Advertisement" width="100%" height="40" frameborder="0" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>`;
287
+ return html.replace(/<main\b[^>]*>/, (main) => `${main}\n${unit}`);
288
+ }
289
+
290
+ /** A page's CSP with room for that ad's frame. */
291
+ export function adCsp(csp: string, ads: GuideAds): string {
292
+ return ads.slot ? `${csp}; frame-src 'self' ${CRAWLPROOF}` : csp;
293
+ }
294
+
277
295
 
278
296
  /** crawlproof analytics and an ad on the guide: a project id (tracker data-site) and an ad slot. */
279
297
  export interface GuideAds {