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.
- package/dist/label-routes.d.ts +3 -0
- package/dist/label-routes.js +3 -2
- package/dist/radio-pages.d.ts +14 -1
- package/dist/radio-pages.js +30 -3
- package/dist/server.js +29 -15
- package/dist/station-guide.d.ts +10 -1
- package/dist/station-guide.js +19 -2
- package/package.json +1 -1
- package/src/label-routes.ts +5 -2
- package/src/radio-pages.ts +28 -3
- package/src/server.ts +28 -15
- package/src/station-guide.ts +20 -2
- package/web/dist/assets/{ar-CCVdTxXd.js → ar-DDphLo2L.js} +1 -1
- package/web/dist/assets/{cs-DazinpO5.js → cs-CMKLJTnw.js} +1 -1
- package/web/dist/assets/{da-dANXD44J.js → da-CibDEkHw.js} +1 -1
- package/web/dist/assets/{de-BH5N_2Zt.js → de-D1BL2QsJ.js} +1 -1
- package/web/dist/assets/{es-lGWlpKlv.js → es-Cj9qr9jo.js} +1 -1
- package/web/dist/assets/{fi-CIIF4ItN.js → fi-DqCO5i_7.js} +1 -1
- package/web/dist/assets/{fr-s0WjlDkZ.js → fr-DUk14AWf.js} +1 -1
- package/web/dist/assets/{hi-Dwrx_fdX.js → hi-U_jzSO9-.js} +1 -1
- package/web/dist/assets/{hu-B7yw75er.js → hu-B28enhWa.js} +1 -1
- package/web/dist/assets/{id-DNdkH2kw.js → id-DlGfGd_6.js} +1 -1
- package/web/dist/assets/{it-C9858eN5.js → it-D3HMQpf5.js} +1 -1
- package/web/dist/assets/{main-Coq6MRdf.js → main-D0eqtyhi.js} +2 -2
- package/web/dist/assets/{nl-BeH2Fgue.js → nl-i1Y9R3xr.js} +1 -1
- package/web/dist/assets/{ru-DZ2nMkFG.js → ru-DjRg1y2P.js} +1 -1
- package/web/dist/assets/{station-DGgba3hs.js → station-DxBlD6Yl.js} +1 -1
- package/web/dist/assets/{sv-CuTZodhi.js → sv-Dsj-93a2.js} +1 -1
- package/web/dist/assets/{uk-Bt534Jo1.js → uk-COWBdTZb.js} +1 -1
- package/web/dist/assets/{vi-Ci0kYdXP.js → vi-CgEMtDCA.js} +1 -1
- package/web/dist/assets/{zh-C8tiLpKW.js → zh-CBa5mJ__.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +1 -1
- package/web/dist/sw.js +20 -20
package/dist/label-routes.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/label-routes.js
CHANGED
|
@@ -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
|
}
|
package/dist/radio-pages.d.ts
CHANGED
|
@@ -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;
|
package/dist/radio-pages.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/station-guide.d.ts
CHANGED
|
@@ -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;
|
package/dist/station-guide.js
CHANGED
|
@@ -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
|
-
|
|
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)}&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
package/src/label-routes.ts
CHANGED
|
@@ -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
|
}
|
package/src/radio-pages.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/station-guide.ts
CHANGED
|
@@ -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
|
-
|
|
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)}&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 {
|