nixamp 0.39.2 → 0.40.0
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/ai-radio.d.ts +2 -0
- package/dist/ai-radio.js +9 -0
- package/dist/ai-routes.d.ts +3 -0
- package/dist/ai-routes.js +26 -1
- package/dist/listener-shares.d.ts +2 -0
- package/dist/listener-shares.js +4 -0
- package/dist/server.d.ts +8 -1
- package/dist/server.js +78 -1
- package/dist/station-cli.js +31 -0
- package/dist/station-mcp.d.ts +144 -14
- package/dist/station-mcp.js +25 -0
- package/dist/station-routes.d.ts +3 -0
- package/dist/station-routes.js +22 -3
- package/dist/station-usage.d.ts +1 -1
- package/dist/station-usage.js +5 -0
- package/dist/support.d.ts +43 -0
- package/dist/support.js +203 -0
- package/dist/track-facts.d.ts +88 -0
- package/dist/track-facts.js +220 -0
- package/package.json +1 -1
- package/src/ai-radio.ts +9 -0
- package/src/ai-routes.ts +29 -1
- package/src/listener-shares.ts +5 -0
- package/src/server.ts +77 -1
- package/src/station-cli.ts +22 -0
- package/src/station-mcp.ts +24 -0
- package/src/station-routes.ts +23 -3
- package/src/station-usage.ts +5 -0
- package/src/support.ts +206 -0
- package/src/track-facts.ts +243 -0
- package/web/dist/assets/{ar-BSu384wE.js → ar-BY-kuED6.js} +1 -1
- package/web/dist/assets/{cs-CycweUsX.js → cs-C6mMbdyp.js} +1 -1
- package/web/dist/assets/{da-C7FbLd8g.js → da-DqV68WRi.js} +1 -1
- package/web/dist/assets/{de-DMcsxdff.js → de-C4JKBX1z.js} +1 -1
- package/web/dist/assets/{es-VnyHTJVp.js → es-B_mF5h1R.js} +1 -1
- package/web/dist/assets/{fi-B-oW_Ilc.js → fi-CDbSPL76.js} +1 -1
- package/web/dist/assets/{fr-CdEDQS5F.js → fr-B_573mv-.js} +1 -1
- package/web/dist/assets/{hi-B_xiLY1e.js → hi--YR7-iSh.js} +1 -1
- package/web/dist/assets/{hu-kFISDmOM.js → hu-kotmcF8p.js} +1 -1
- package/web/dist/assets/{id-DMel0ct7.js → id-CY1mU0Fh.js} +1 -1
- package/web/dist/assets/{it-1LsKNR45.js → it-Dj4xHmAf.js} +1 -1
- package/web/dist/assets/{main-CJLyq0BI.js → main-CwBTD5i3.js} +2 -2
- package/web/dist/assets/{nl-bZUFxlcO.js → nl-DVvshFko.js} +1 -1
- package/web/dist/assets/{ru-BI9zEd6Z.js → ru-BMwolR6_.js} +1 -1
- package/web/dist/assets/station-BZ2CtCcH.js +1 -0
- package/web/dist/assets/{station-Bq-LiY6G.css → station-CM31TuOq.css} +1 -1
- package/web/dist/assets/{sv-CD5WRIGc.js → sv-B_VngG1H.js} +1 -1
- package/web/dist/assets/{uk-BVzlKtfs.js → uk-D9pKHqQ7.js} +1 -1
- package/web/dist/assets/{vi-BV-dHawO.js → vi-BezqX85w.js} +1 -1
- package/web/dist/assets/{zh-DiT7lmM0.js → zh-CjTmbtGB.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +7 -2
- package/web/dist/sw.js +21 -21
- package/web/dist/assets/station-DDvu2lUI.js +0 -1
package/src/ai-radio.ts
CHANGED
|
@@ -302,6 +302,15 @@ export class AiRadio {
|
|
|
302
302
|
return this.stations.get(key);
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/** The track AI radio composed at this path, if it did. */
|
|
306
|
+
trackAt(file: string): AiTrack | undefined {
|
|
307
|
+
for (const station of this.stations.values()) {
|
|
308
|
+
const hit = station.tracks.find((one) => one.file === file);
|
|
309
|
+
if (hit) return hit;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
305
314
|
/** What the owner sees: the stations, this month's seconds, and why nothing is being made. */
|
|
306
315
|
status(): Record<string, unknown> {
|
|
307
316
|
const month = monthOf(this.now());
|
package/src/ai-routes.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { AI_KEY_NAMES, aiKeysSet, saveAiKey, type AiKeyName } from "./ai-keys.ts
|
|
|
13
13
|
import type { AiRadio } from "./ai-radio.ts";
|
|
14
14
|
import { SHARE_BASES, SHARE_GENRES, ownerShareView, sharerView, type ListenerShares } from "./listener-shares.ts";
|
|
15
15
|
import { StationError } from "./stations.ts";
|
|
16
|
+
import type { HolderStore } from "./track-facts.ts";
|
|
16
17
|
|
|
17
18
|
const PUBLIC = /^\/api\/v1\/share(?:\/(upload|report|[a-f0-9]{12}))?$/;
|
|
18
19
|
|
|
@@ -24,6 +25,8 @@ export function isPublicSharePath(path: string): boolean {
|
|
|
24
25
|
export interface AiRoutesContext {
|
|
25
26
|
ai?: AiRadio;
|
|
26
27
|
shares?: ListenerShares;
|
|
28
|
+
/** The copyright holders the owner registered, for the now-playing card. */
|
|
29
|
+
holders?: HolderStore;
|
|
27
30
|
canControl: boolean;
|
|
28
31
|
/** Who is asking, for per-sharer limits (an address; hashed before it is kept). */
|
|
29
32
|
caller: string;
|
|
@@ -57,7 +60,8 @@ export async function handleAiRoutes(
|
|
|
57
60
|
): Promise<boolean> {
|
|
58
61
|
const method = request.method ?? "GET";
|
|
59
62
|
const publicPath = PUBLIC.exec(path);
|
|
60
|
-
const ownerPath = path === "/api/v1/stations/ai" || path.startsWith("/api/v1/stations/ai/") || path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")
|
|
63
|
+
const ownerPath = path === "/api/v1/stations/ai" || path.startsWith("/api/v1/stations/ai/") || path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")
|
|
64
|
+
|| path === "/api/v1/stations/holders" || path.startsWith("/api/v1/stations/holders/");
|
|
61
65
|
if (!publicPath && !ownerPath) return false;
|
|
62
66
|
try {
|
|
63
67
|
if (publicPath) {
|
|
@@ -114,6 +118,30 @@ export async function handleAiRoutes(
|
|
|
114
118
|
return true;
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
if (path === "/api/v1/stations/holders" || path.startsWith("/api/v1/stations/holders/")) {
|
|
122
|
+
const holders = ctx.holders;
|
|
123
|
+
if (!holders) {
|
|
124
|
+
ctx.json(response, 404, { error: "copyright holders are not kept here", code: "not_found" });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
if (path === "/api/v1/stations/holders" && method === "GET") {
|
|
128
|
+
ctx.json(response, 200, { holders: holders.list() });
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (path === "/api/v1/stations/holders" && method === "POST") {
|
|
132
|
+
ctx.json(response, 200, { holder: holders.set(await jsonBody(ctx, request)) });
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
const one = /^\/api\/v1\/stations\/holders\/([a-z0-9-]{1,40})$/.exec(path);
|
|
136
|
+
if (one && method === "DELETE") {
|
|
137
|
+
if (!holders.remove(one[1]!)) throw new StationError("not_found", "no such holder", 404);
|
|
138
|
+
ctx.json(response, 200, { deleted: one[1] });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
ctx.json(response, 404, { error: "no such holder route", code: "not_found" });
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
if (path === "/api/v1/stations/shares" || path.startsWith("/api/v1/stations/shares/")) {
|
|
118
146
|
const shares = ctx.shares;
|
|
119
147
|
if (!shares) {
|
package/src/listener-shares.ts
CHANGED
|
@@ -215,6 +215,11 @@ export class ListenerShares {
|
|
|
215
215
|
return this.shares.get(id);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** The share whose copy is this file, if it is one. */
|
|
219
|
+
atFile(file: string): Share | undefined {
|
|
220
|
+
return [...this.shares.values()].find((one) => one.file === file);
|
|
221
|
+
}
|
|
222
|
+
|
|
218
223
|
/** The share, if this token is the one its sharer was given. */
|
|
219
224
|
forSharer(id: string, token: string): Share | undefined {
|
|
220
225
|
const share = this.shares.get(id);
|
package/src/server.ts
CHANGED
|
@@ -80,6 +80,8 @@ import { handleHandoffs } from "./handoff-routes.ts";
|
|
|
80
80
|
import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
|
|
81
81
|
import { AiRadio } from "./ai-radio.ts";
|
|
82
82
|
import { ListenerShares } from "./listener-shares.ts";
|
|
83
|
+
import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
|
|
84
|
+
import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.ts";
|
|
83
85
|
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
84
86
|
import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
|
|
85
87
|
import { StationRunner } from "./station-runner.ts";
|
|
@@ -1736,6 +1738,10 @@ export interface HandlerOptions {
|
|
|
1736
1738
|
ai?: AiRadio;
|
|
1737
1739
|
/** Tracks anybody may share, sorted onto the air by AI. */
|
|
1738
1740
|
shares?: ListenerShares;
|
|
1741
|
+
/** What a listener may know about the track on the air. */
|
|
1742
|
+
facts?: (station: Station, file: string) => Promise<import("./track-facts.ts").TrackFacts>;
|
|
1743
|
+
/** Copyright holders, matched to tracks by artist or station. */
|
|
1744
|
+
holders?: import("./track-facts.ts").HolderStore;
|
|
1739
1745
|
/** Proxies (by address) whose X-Forwarded-For is believed, for per-sharer limits. */
|
|
1740
1746
|
trustedProxies?: () => Set<string>;
|
|
1741
1747
|
/** Other servers' public stations, for this guide to list beside its own. */
|
|
@@ -1914,6 +1920,8 @@ export interface HandlerOptions {
|
|
|
1914
1920
|
speech?: Speech;
|
|
1915
1921
|
liveVoice?: LiveVoice;
|
|
1916
1922
|
translationPasses?: TranslationPasses;
|
|
1923
|
+
/** Payments to copyright holders nixamp.com collects for (support.ts). */
|
|
1924
|
+
support?: Support;
|
|
1917
1925
|
/** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
|
|
1918
1926
|
coinpayWebhookSecret?: string;
|
|
1919
1927
|
sharedTranslations?: SharedTranslations;
|
|
@@ -2687,6 +2695,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2687
2695
|
!isPublicSharePath(path) &&
|
|
2688
2696
|
// What is on, as MCP, for anybody.
|
|
2689
2697
|
path !== "/mcp/public" &&
|
|
2698
|
+
// Supporting a copyright holder is for anybody listening.
|
|
2699
|
+
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2690
2700
|
// A station's short link, which sends the browser to wherever it plays.
|
|
2691
2701
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2692
2702
|
!STATION_PAGE.test(path) &&
|
|
@@ -2753,6 +2763,50 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2753
2763
|
* in the directory). It sends the browser to the station's own page, on
|
|
2754
2764
|
* the server that plays it. Nothing private or unlisted is ever found.
|
|
2755
2765
|
*/
|
|
2766
|
+
/* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
|
|
2767
|
+
const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
|
|
2768
|
+
if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
|
|
2769
|
+
if (!options.support.holder(supportPage[1]!)) {
|
|
2770
|
+
json(response, 404, { error: "nobody by that name is supported here", code: "not_found" });
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache", "content-security-policy": SUPPORT_CSP, "referrer-policy": "no-referrer", "x-content-type-options": "nosniff" });
|
|
2774
|
+
response.end(request.method === "HEAD" ? undefined : SUPPORT_PAGE);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
const supportApi = /^\/api\/v1\/support\/([a-z0-9-]{1,40})$/.exec(path);
|
|
2778
|
+
if (supportApi && options.support) {
|
|
2779
|
+
const support = options.support;
|
|
2780
|
+
const holder = support.holder(supportApi[1]!);
|
|
2781
|
+
if (!holder) {
|
|
2782
|
+
json(response, 404, { error: "nobody by that name is supported here", code: "not_found" });
|
|
2783
|
+
return;
|
|
2784
|
+
}
|
|
2785
|
+
try {
|
|
2786
|
+
if (request.method === "GET") {
|
|
2787
|
+
const { coins } = support.enabled ? await support.coins() : { coins: [] as string[] };
|
|
2788
|
+
json(response, 200, { id: holder.id, name: holder.name, coins, ...(support.enabled ? {} : { error: "Support payments are not set up here." }) });
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
if (request.method === "POST") {
|
|
2792
|
+
const verdict = guard.check(`support:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
|
|
2793
|
+
if (!verdict.ok) {
|
|
2794
|
+
json(response, 429, { error: "too many at once; wait a minute", code: "rate_limited", retryAfter: verdict.retryAfter });
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
let body: Record<string, unknown> = {};
|
|
2798
|
+
try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
|
|
2799
|
+
json(response, 200, await support.checkout({ ...body, holder: holder.id }));
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
|
|
2803
|
+
} catch (error) {
|
|
2804
|
+
if (error instanceof SupportError) json(response, error.status, { error: error.message, code: error.code });
|
|
2805
|
+
else json(response, 502, { error: "the payment service did not answer; nothing was charged", code: "provider" });
|
|
2806
|
+
}
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2756
2810
|
const stationShortLink = /^\/watch\/([a-z0-9]{10})$/.exec(path);
|
|
2757
2811
|
if (stationShortLink && options.stations && (request.method === "GET" || request.method === "HEAD")) {
|
|
2758
2812
|
const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
|
|
@@ -2856,7 +2910,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2856
2910
|
return;
|
|
2857
2911
|
}
|
|
2858
2912
|
|
|
2859
|
-
if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares"))) {
|
|
2913
|
+
if (options.stations && (isPublicSharePath(path) || path.startsWith("/api/v1/stations/ai") || path.startsWith("/api/v1/stations/shares") || path.startsWith("/api/v1/stations/holders"))) {
|
|
2860
2914
|
const control = await controlOf();
|
|
2861
2915
|
// Behind a proxy we trust (dev2 for live.streaming), the sharer is the
|
|
2862
2916
|
// first address it names; otherwise whoever connected.
|
|
@@ -2866,6 +2920,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2866
2920
|
const handled = await handleAiRoutes(request, response, path, url, {
|
|
2867
2921
|
...(options.stations.ai ? { ai: options.stations.ai } : {}),
|
|
2868
2922
|
...(options.stations.shares ? { shares: options.stations.shares } : {}),
|
|
2923
|
+
...(options.stations.holders ? { holders: options.stations.holders } : {}),
|
|
2869
2924
|
canControl: control.allowed,
|
|
2870
2925
|
caller,
|
|
2871
2926
|
json,
|
|
@@ -2916,6 +2971,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2916
2971
|
...(options.stations.usage ? { usage: options.stations.usage } : {}),
|
|
2917
2972
|
...(options.stations.limits ? { limits: options.stations.limits } : {}),
|
|
2918
2973
|
...(options.stations.auto ? { auto: options.stations.auto } : {}),
|
|
2974
|
+
...(options.stations.facts ? { facts: options.stations.facts } : {}),
|
|
2919
2975
|
...(options.stations.peers ? { peers: () => options.stations!.peers!.entries() } : {}),
|
|
2920
2976
|
throttle: () => {
|
|
2921
2977
|
const verdict = guard.check(`stations:${control.account || callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, STATION_CONTROL_LIMIT);
|
|
@@ -7006,6 +7062,23 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7006
7062
|
});
|
|
7007
7063
|
stations.ai = aiRadio;
|
|
7008
7064
|
stations.shares = listenerShares;
|
|
7065
|
+
// The now-playing card: the file's own tags, where it came from here, and
|
|
7066
|
+
// the copyright holders the owner registered.
|
|
7067
|
+
const tagReader = new TagReader(tools.ffprobe);
|
|
7068
|
+
const holderStore = new HolderStore(HolderStore.fileFor(stateDir(), options.port));
|
|
7069
|
+
stations.holders = holderStore;
|
|
7070
|
+
stations.facts = async (station, file) => {
|
|
7071
|
+
const share = listenerShares.atFile(file);
|
|
7072
|
+
const composed = aiRadio.trackAt(file);
|
|
7073
|
+
return factsFor({
|
|
7074
|
+
station, file,
|
|
7075
|
+
tags: await tagReader.read(file),
|
|
7076
|
+
known: trackFor(engine, file),
|
|
7077
|
+
holders: holderStore.list(),
|
|
7078
|
+
share: share ? { basis: share.rights.basis, note: share.rights.note, by: share.by } : null,
|
|
7079
|
+
aiTrack: composed ? { made: composed.made } : null,
|
|
7080
|
+
});
|
|
7081
|
+
};
|
|
7009
7082
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
7010
7083
|
// channel's fragments into short files while somebody is asking for them.
|
|
7011
7084
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -7138,6 +7211,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7138
7211
|
// is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
|
|
7139
7212
|
const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
|
|
7140
7213
|
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
|
|
7214
|
+
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
7215
|
+
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
7141
7216
|
if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
|
|
7142
7217
|
const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
|
|
7143
7218
|
...(pool ? { db: pool } : {}),
|
|
@@ -7690,6 +7765,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7690
7765
|
...(speech ? { speech } : {}),
|
|
7691
7766
|
...(liveVoice ? { liveVoice } : {}),
|
|
7692
7767
|
...(translationPasses ? { translationPasses } : {}),
|
|
7768
|
+
...(supportPayments ? { support: supportPayments } : {}),
|
|
7693
7769
|
...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
|
|
7694
7770
|
...(sharedTranslations ? { sharedTranslations } : {}),
|
|
7695
7771
|
...(translator ? { translator } : {}),
|
package/src/station-cli.ts
CHANGED
|
@@ -253,6 +253,28 @@ export async function station(argv: string[]): Promise<number> {
|
|
|
253
253
|
return await aiCommand(argv.slice(1), call, show, json);
|
|
254
254
|
case "shares":
|
|
255
255
|
return await sharesCommand(argv.slice(1), call, show);
|
|
256
|
+
case "holder":
|
|
257
|
+
case "holders": {
|
|
258
|
+
const sub = argv[1] ?? "list";
|
|
259
|
+
const all = (flagName: string): string[] => argv.flatMap((one, at) => (one === flagName && argv[at + 1] ? [argv[at + 1]!] : []));
|
|
260
|
+
const line = (h: Record<string, unknown>): string =>
|
|
261
|
+
`${String(h["name"])} [${String(h["id"])}]\n artists ${((h["artists"] as string[]) ?? []).join(", ") || "-"}\n stations ${((h["stations"] as string[]) ?? []).join(", ") || "-"}\n support ${String(h["support"] || "-")}${h["license"] ? `\n licence ${String(h["license"])}` : ""}`;
|
|
262
|
+
if (sub === "list") return show(await call("GET", "/api/v1/stations/holders"), (body) => ((body["holders"] as Record<string, unknown>[]) ?? []).map(line).join("\n\n") || "No copyright holders yet. nixamp station holder set NAME --artist ARTIST --support URL");
|
|
263
|
+
if (sub === "remove") return show(await call("DELETE", `/api/v1/stations/holders/${argv[2] ?? ""}`), () => `Removed ${argv[2]}.`);
|
|
264
|
+
if (sub === "set") {
|
|
265
|
+
const name = argv[2];
|
|
266
|
+
if (!name || name.startsWith("--")) { console.error("nixamp station holder set needs a NAME"); return 2; }
|
|
267
|
+
const body: Record<string, unknown> = { name };
|
|
268
|
+
if (all("--artist").length) body["artists"] = all("--artist");
|
|
269
|
+
if (all("--station").length) body["stations"] = all("--station");
|
|
270
|
+
if (flag(argv, "--support")) body["support"] = flag(argv, "--support");
|
|
271
|
+
if (flag(argv, "--label")) body["label"] = flag(argv, "--label");
|
|
272
|
+
if (flag(argv, "--license")) body["license"] = flag(argv, "--license");
|
|
273
|
+
return show(await call("POST", "/api/v1/stations/holders", body), (answer) => line(answer["holder"] as Record<string, unknown>));
|
|
274
|
+
}
|
|
275
|
+
console.error(`nixamp station holder: no command "${sub}".\n\n${USAGE}`);
|
|
276
|
+
return 2;
|
|
277
|
+
}
|
|
256
278
|
case "delete": {
|
|
257
279
|
const station = needId();
|
|
258
280
|
if (!station) return 2;
|
package/src/station-mcp.ts
CHANGED
|
@@ -123,6 +123,21 @@ export const STATION_TOOLS = [
|
|
|
123
123
|
},
|
|
124
124
|
},
|
|
125
125
|
},
|
|
126
|
+
{
|
|
127
|
+
name: "station_holders",
|
|
128
|
+
description: "Copyright holders for the now-playing card: who holds a track's copyright (matched by artist name or station) and the link that supports them. action list (default), set (name, artists, stations, support https URL, label, license), remove (id).",
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {
|
|
132
|
+
server: SERVER,
|
|
133
|
+
action: { type: "string", enum: ["list", "set", "remove"] },
|
|
134
|
+
id: STRING, name: STRING,
|
|
135
|
+
artists: { type: "array", items: STRING }, stations: { type: "array", items: STRING },
|
|
136
|
+
support: { ...STRING, description: "https link where listeners subscribe or donate, e.g. https://nixamp.com/support/profullstack" },
|
|
137
|
+
label: STRING, license: STRING,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
126
141
|
{
|
|
127
142
|
name: "station_unpublish",
|
|
128
143
|
description: "Take a station out of the directory, or make it private: its links stop working and listeners are disconnected.",
|
|
@@ -213,6 +228,15 @@ export async function callStationTool(name: string, args: Record<string, unknown
|
|
|
213
228
|
if (answer === undefined) return say("action is status, create, generate, update, remove, budget or resume", true);
|
|
214
229
|
return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
|
|
215
230
|
}
|
|
231
|
+
if (name === "station_holders") {
|
|
232
|
+
const action = String(args["action"] ?? "list");
|
|
233
|
+
const answer = action === "list" ? await call("GET", "/api/v1/stations/holders")
|
|
234
|
+
: action === "set" ? await call("POST", "/api/v1/stations/holders", pick(["id", "name", "artists", "stations", "support", "label", "license"]))
|
|
235
|
+
: action === "remove" && typeof args["id"] === "string" ? await call("DELETE", `/api/v1/stations/holders/${encodeURIComponent(args["id"])}`)
|
|
236
|
+
: null;
|
|
237
|
+
if (!answer) return say("action is list, set or remove (remove needs id)", true);
|
|
238
|
+
return answer.ok ? say(JSON.stringify(answer.body, null, 2)) : refused(answer.body);
|
|
239
|
+
}
|
|
216
240
|
if (name === "station_shares") {
|
|
217
241
|
const action = String(args["action"] ?? "status");
|
|
218
242
|
const share = typeof args["id"] === "string" && /^[a-f0-9]{12}$/.test(args["id"]) ? args["id"] : "";
|
package/src/station-routes.ts
CHANGED
|
@@ -16,12 +16,13 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
16
16
|
import type { Channels } from "./channels.ts";
|
|
17
17
|
import type { StationRunner } from "./station-runner.ts";
|
|
18
18
|
import { alertsFor, type Limits, type StationUsage } from "./station-metering.ts";
|
|
19
|
+
import type { TrackFacts } from "./track-facts.ts";
|
|
19
20
|
import {
|
|
20
21
|
StationError, channelOf, tokensMatch, type OwnerStation, type PublicStation, type Station, type StationStore,
|
|
21
22
|
} from "./stations.ts";
|
|
22
23
|
|
|
23
24
|
/** The three things a station's link opens. */
|
|
24
|
-
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events)$/;
|
|
25
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now)$/;
|
|
25
26
|
|
|
26
27
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
27
28
|
export function isStationListenPath(path: string): boolean {
|
|
@@ -59,6 +60,8 @@ export interface StationContext {
|
|
|
59
60
|
limits?: Limits;
|
|
60
61
|
/** Null to go ahead with a change, or how long to wait (control rate limit). */
|
|
61
62
|
throttle?: () => { retryAfter: number } | null;
|
|
63
|
+
/** What a listener may know about the track on the air (track-facts.ts). */
|
|
64
|
+
facts?: (station: Station, file: string) => Promise<TrackFacts>;
|
|
62
65
|
/** Auto stations: run a sync with these settings (and keep them), or read them. */
|
|
63
66
|
auto?: { sync: (input: Record<string, unknown>) => Promise<unknown>; setting: () => unknown };
|
|
64
67
|
/** The public stations other servers list (guide peers); absent when there are none. */
|
|
@@ -210,7 +213,19 @@ export async function handleStations(
|
|
|
210
213
|
const view = publicView(ctx, station);
|
|
211
214
|
return { station: station.id, playback: `${station.id}:${ctx.channels.info(channelOf(station.id))?.startedAt ?? 0}`, track: `${station.id}:${instance}`, at: Date.now(), state: view.state, nowPlaying: view.nowPlaying };
|
|
212
215
|
};
|
|
213
|
-
|
|
216
|
+
// Each "now" carries what is known about the track: who made it, AI or
|
|
217
|
+
// not, whose copyright, how to support them.
|
|
218
|
+
const withFacts = async (): Promise<Record<string, unknown>> => {
|
|
219
|
+
const payload = now();
|
|
220
|
+
const file = nowPlayingFile(ctx, station);
|
|
221
|
+
if (!ctx.facts || !file) return payload;
|
|
222
|
+
try {
|
|
223
|
+
return { ...payload, facts: await ctx.facts(station, file) };
|
|
224
|
+
} catch {
|
|
225
|
+
return payload;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
void withFacts().then((payload) => send("hello", payload));
|
|
214
229
|
const tick = setInterval(() => {
|
|
215
230
|
const current = ctx.store.get(station.id);
|
|
216
231
|
if (!current) {
|
|
@@ -222,7 +237,7 @@ export async function handleStations(
|
|
|
222
237
|
if (file !== lastFile) {
|
|
223
238
|
lastFile = file;
|
|
224
239
|
instance += 1;
|
|
225
|
-
send("now",
|
|
240
|
+
void withFacts().then((payload) => send("now", payload));
|
|
226
241
|
} else if (state !== lastState) {
|
|
227
242
|
send("state", now());
|
|
228
243
|
}
|
|
@@ -241,6 +256,11 @@ export async function handleStations(
|
|
|
241
256
|
response.on("close", leave);
|
|
242
257
|
return true;
|
|
243
258
|
}
|
|
259
|
+
if (what === "now") {
|
|
260
|
+
const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
261
|
+
ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
244
264
|
if (what === "state") {
|
|
245
265
|
ctx.json(response, 200, publicView(ctx, station));
|
|
246
266
|
return true;
|
package/src/station-usage.ts
CHANGED
|
@@ -41,6 +41,11 @@ export const USAGE = `nixamp station — make a collection into a station
|
|
|
41
41
|
nixamp station shares approve|reject|delete SHARE
|
|
42
42
|
nixamp station shares trust ADDRESS [--remove] believe this proxy's X-Forwarded-For
|
|
43
43
|
|
|
44
|
+
Copyright holders (the now-playing card: who holds a track's copyright, and how to support them):
|
|
45
|
+
nixamp station holder list
|
|
46
|
+
nixamp station holder set NAME [--artist ARTIST]... [--station ID]... [--support https://...] [--label Subscribe] [--license TEXT]
|
|
47
|
+
nixamp station holder remove ID
|
|
48
|
+
|
|
44
49
|
--json machine-readable output
|
|
45
50
|
--url, --key another server, as with \`nixamp admin\`
|
|
46
51
|
|
package/src/support.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Support a copyright holder: a payment through nixamp.com's own CoinPay
|
|
3
|
+
* business, for the holders whose money that business may take.
|
|
4
|
+
*
|
|
5
|
+
* A station's now-playing card links a track's holder to their support page.
|
|
6
|
+
* Holders who collect for themselves register their own link on the station
|
|
7
|
+
* (`nixamp station holder set NAME --support URL`); this page is for the
|
|
8
|
+
* holders nixamp.com collects for -- today Profullstack's own music (the
|
|
9
|
+
* band Þrøngva, AI radio) -- named in NIXAMP_SUPPORT_HOLDERS ("id=Name,...").
|
|
10
|
+
*
|
|
11
|
+
* One payment, in crypto, on CoinPay's hosted checkout. CoinPay offers no
|
|
12
|
+
* crypto subscription a business key can start (its plan/subscribe API is
|
|
13
|
+
* Stripe's, which we do not use), so "monthly" is not offered rather than
|
|
14
|
+
* pretended.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
export interface SupportHolder { id: string; name: string }
|
|
19
|
+
|
|
20
|
+
export function supportHoldersFrom(env: Record<string, string | undefined> = process.env): SupportHolder[] {
|
|
21
|
+
const raw = env["NIXAMP_SUPPORT_HOLDERS"] ?? "profullstack=Profullstack";
|
|
22
|
+
return raw.split(",").map((one) => one.trim()).filter(Boolean).map((one) => {
|
|
23
|
+
const [id, ...name] = one.split("=");
|
|
24
|
+
return { id: (id ?? "").trim().toLowerCase(), name: name.join("=").trim() || (id ?? "").trim() };
|
|
25
|
+
}).filter((one) => /^[a-z0-9-]{1,40}$/.test(one.id));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const AMOUNTS = [3, 5, 10, 25, 50];
|
|
29
|
+
const MIN_USD = 1;
|
|
30
|
+
const MAX_USD = 1000;
|
|
31
|
+
|
|
32
|
+
export class SupportError extends Error {
|
|
33
|
+
constructor(message: string, readonly status = 400, readonly code = "invalid") {
|
|
34
|
+
super(message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class Support {
|
|
39
|
+
private merchant: Promise<{ business: string; coins: string[] }> | null = null;
|
|
40
|
+
private merchantUntil = 0;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly options: { key: string; site: string; holders: SupportHolder[]; fetcher?: typeof fetch; now?: () => number }) {}
|
|
43
|
+
|
|
44
|
+
holder(id: string): SupportHolder | undefined {
|
|
45
|
+
return this.options.holders.find((one) => one.id === id.toLowerCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get enabled(): boolean {
|
|
49
|
+
return Boolean(this.options.key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async provider(path: string, init: RequestInit = {}): Promise<Record<string, unknown>> {
|
|
53
|
+
if (!this.options.key) throw new SupportError("Support payments are not set up here.", 503, "unavailable");
|
|
54
|
+
const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com${path}`, {
|
|
55
|
+
...init,
|
|
56
|
+
signal: AbortSignal.timeout(15_000),
|
|
57
|
+
headers: { authorization: `Bearer ${this.options.key}`, "content-type": "application/json", ...(init.headers ?? {}) },
|
|
58
|
+
});
|
|
59
|
+
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
60
|
+
if (!response.ok || body["success"] !== true) throw new SupportError("The payment service did not answer; nothing was charged. Try again.", 502, "provider");
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The business and the coins it has a wallet for, asked at most every 10 minutes. */
|
|
65
|
+
async coins(): Promise<{ business: string; coins: string[] }> {
|
|
66
|
+
const now = this.options.now?.() ?? Date.now();
|
|
67
|
+
if (!this.merchant || now >= this.merchantUntil) {
|
|
68
|
+
this.merchantUntil = now + 600_000;
|
|
69
|
+
this.merchant = this.provider("/api/supported-coins").then((body) => {
|
|
70
|
+
const business = String(body["business_id"] ?? "");
|
|
71
|
+
if (!/^[0-9a-f-]{36}$/.test(business)) throw new SupportError("Support payments are not set up here.", 503, "unavailable");
|
|
72
|
+
const coins = (Array.isArray(body["coins"]) ? body["coins"] : [])
|
|
73
|
+
.filter((coin): coin is { symbol: string; is_active: boolean; has_wallet: boolean } => typeof coin === "object" && coin !== null)
|
|
74
|
+
.filter((coin) => coin.is_active && coin.has_wallet && typeof coin.symbol === "string")
|
|
75
|
+
.map((coin) => coin.symbol);
|
|
76
|
+
return { business, coins };
|
|
77
|
+
}).catch((error: unknown) => { this.merchant = null; throw error; });
|
|
78
|
+
}
|
|
79
|
+
return this.merchant;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A CoinPay checkout for this much, to this holder; the address to send the payer to. */
|
|
83
|
+
async checkout(input: { holder?: unknown; amountUsd?: unknown; coin?: unknown; station?: unknown; track?: unknown }): Promise<{ url: string }> {
|
|
84
|
+
const holder = typeof input.holder === "string" ? this.holder(input.holder) : undefined;
|
|
85
|
+
if (!holder) throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
|
|
86
|
+
const amount = Number(input.amountUsd);
|
|
87
|
+
if (!Number.isFinite(amount) || amount < MIN_USD || amount > MAX_USD) throw new SupportError(`Choose an amount from $${MIN_USD} to $${MAX_USD}.`, 400, "invalid_amount");
|
|
88
|
+
const { business, coins } = await this.coins();
|
|
89
|
+
const coin = typeof input.coin === "string" ? input.coin.toUpperCase() : "";
|
|
90
|
+
if (!coins.includes(coin)) throw new SupportError(`Pay with one of: ${coins.join(", ")}.`, 400, "invalid_coin");
|
|
91
|
+
const clean = (value: unknown): string => (typeof value === "string" ? value.replace(/[\u0000-\u001f]/g, " ").slice(0, 120) : "");
|
|
92
|
+
const order = randomUUID();
|
|
93
|
+
const created = await this.provider("/api/payments/create", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: { "idempotency-key": `nixamp-support-${order}` },
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
business_id: business,
|
|
98
|
+
amount_usd: Math.round(amount * 100) / 100,
|
|
99
|
+
currency: coin.toLowerCase(),
|
|
100
|
+
payment_method: "crypto",
|
|
101
|
+
description: `Support ${holder.name} on nixamp`,
|
|
102
|
+
metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track) },
|
|
103
|
+
redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
const payment = created["payment"] as { id?: unknown } | undefined;
|
|
107
|
+
const id = typeof payment?.id === "string" ? payment.id : "";
|
|
108
|
+
if (!/^[A-Za-z0-9_-]{6,80}$/.test(id)) throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
|
|
109
|
+
return { url: `https://coinpayportal.com/pay/${id}` };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const STYLE = `
|
|
114
|
+
:root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
|
|
115
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
116
|
+
main{max-width:520px;margin:0 auto;padding:48px 16px}
|
|
117
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
118
|
+
h1{margin:0 0 6px;font-size:1.5rem}p{color:var(--muted);margin:0 0 18px}
|
|
119
|
+
.amounts{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 14px}
|
|
120
|
+
.amounts button{flex:1 1 64px;padding:10px;border-radius:10px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;font-weight:700;cursor:pointer}
|
|
121
|
+
.amounts button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}
|
|
122
|
+
label{display:block;color:var(--muted);font-size:.9rem;margin:10px 0 4px}
|
|
123
|
+
input,select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
|
|
124
|
+
button.go{margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
|
|
125
|
+
button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
|
126
|
+
#status{min-height:1.4em;margin-top:12px;color:var(--muted)}
|
|
127
|
+
.thanks{color:var(--accent);font-weight:700}
|
|
128
|
+
small{display:block;margin-top:18px;color:var(--muted)}small a{color:inherit}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const SCRIPT = `
|
|
132
|
+
(async () => {
|
|
133
|
+
const holder = location.pathname.split("/").filter(Boolean)[1] || "";
|
|
134
|
+
const params = new URLSearchParams(location.search);
|
|
135
|
+
const $ = (s) => document.querySelector(s);
|
|
136
|
+
let amount = 5;
|
|
137
|
+
for (const b of document.querySelectorAll(".amounts button")) {
|
|
138
|
+
b.addEventListener("click", () => {
|
|
139
|
+
amount = Number(b.dataset.usd);
|
|
140
|
+
$("#other").value = "";
|
|
141
|
+
for (const o of document.querySelectorAll(".amounts button")) o.setAttribute("aria-pressed", String(o === b));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
|
|
145
|
+
try {
|
|
146
|
+
const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
|
|
147
|
+
if (info.name) { $("#who").textContent = info.name; document.title = "Support " + info.name + " · nixamp"; }
|
|
148
|
+
const coins = info.coins || [];
|
|
149
|
+
$("#coin").replaceChildren(...coins.map((c) => { const o = document.createElement("option"); o.value = c; o.textContent = c.replace(/_/g, " "); return o; }));
|
|
150
|
+
if (!coins.length) { $("#go").disabled = true; $("#status").textContent = info.error || "Payments are not available right now."; }
|
|
151
|
+
} catch { $("#status").textContent = "Payments are not available right now."; }
|
|
152
|
+
$("#form").addEventListener("submit", async (event) => {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
const other = Number($("#other").value);
|
|
155
|
+
const usd = other > 0 ? other : amount;
|
|
156
|
+
$("#go").disabled = true;
|
|
157
|
+
$("#status").textContent = "Starting the checkout…";
|
|
158
|
+
try {
|
|
159
|
+
const r = await fetch("/api/v1/support/" + encodeURIComponent(holder), { method: "POST", headers: { "content-type": "application/json" },
|
|
160
|
+
body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "" }) });
|
|
161
|
+
const body = await r.json();
|
|
162
|
+
if (r.ok && body.url) { location.href = body.url; return; }
|
|
163
|
+
$("#status").textContent = body.error || "That did not work.";
|
|
164
|
+
} catch { $("#status").textContent = "Could not reach the payment service."; }
|
|
165
|
+
$("#go").disabled = false;
|
|
166
|
+
});
|
|
167
|
+
})();
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
171
|
+
|
|
172
|
+
export const SUPPORT_PAGE = `<!doctype html>
|
|
173
|
+
<html lang="en">
|
|
174
|
+
<head>
|
|
175
|
+
<meta charset="utf-8">
|
|
176
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
177
|
+
<title>Support · nixamp</title>
|
|
178
|
+
<link rel="icon" href="/favicon.png">
|
|
179
|
+
<style>${STYLE}</style>
|
|
180
|
+
</head>
|
|
181
|
+
<body>
|
|
182
|
+
<main>
|
|
183
|
+
<div class="card">
|
|
184
|
+
<h1>Support <span id="who">the artist</span></h1>
|
|
185
|
+
<p>Pay the copyright holder of the music you are listening to, directly. One payment, in crypto, through CoinPay.</p>
|
|
186
|
+
<form id="form">
|
|
187
|
+
<div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
|
|
188
|
+
<label for="other">Or another amount (US$)</label>
|
|
189
|
+
<input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
|
|
190
|
+
<label for="coin">Pay with</label>
|
|
191
|
+
<select id="coin"></select>
|
|
192
|
+
<button id="go" class="go" type="submit">Continue to payment</button>
|
|
193
|
+
<div id="status" role="status" aria-live="polite"></div>
|
|
194
|
+
</form>
|
|
195
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Nothing recurs.</small>
|
|
196
|
+
</div>
|
|
197
|
+
</main>
|
|
198
|
+
<script>${SCRIPT}</script>
|
|
199
|
+
</body>
|
|
200
|
+
</html>
|
|
201
|
+
`;
|
|
202
|
+
|
|
203
|
+
export const SUPPORT_CSP = [
|
|
204
|
+
"default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
|
|
205
|
+
"img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
|
|
206
|
+
].join("; ");
|