nixamp 0.41.1 → 0.42.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.
Files changed (30) hide show
  1. package/dist/server.d.ts +3 -1
  2. package/dist/server.js +36 -3
  3. package/dist/station-guide.d.ts +10 -1
  4. package/dist/station-guide.js +17 -4
  5. package/dist/support.d.ts +65 -0
  6. package/dist/support.js +187 -5
  7. package/package.json +1 -1
  8. package/src/server.ts +36 -3
  9. package/src/station-guide.ts +17 -4
  10. package/src/support.ts +201 -6
  11. package/web/dist/assets/{ar-BSokqBTY.js → ar-S4rqddex.js} +1 -1
  12. package/web/dist/assets/{cs-B9taw8Ps.js → cs-BShF2rip.js} +1 -1
  13. package/web/dist/assets/{da-vmuHyz8I.js → da-BO87iZOE.js} +1 -1
  14. package/web/dist/assets/{de-CWV-UGto.js → de-CUANqdKo.js} +1 -1
  15. package/web/dist/assets/{es-cjyWEn7s.js → es-BdUyDUC0.js} +1 -1
  16. package/web/dist/assets/{fi-DE6zUbIW.js → fi-BBBq-Bd8.js} +1 -1
  17. package/web/dist/assets/{fr-DOKMh3Nf.js → fr-DqZ3HEY8.js} +1 -1
  18. package/web/dist/assets/{hi-DDmREWrp.js → hi-BBMF8sCD.js} +1 -1
  19. package/web/dist/assets/{hu-D-gj8zTM.js → hu-D1LEEcho.js} +1 -1
  20. package/web/dist/assets/{id-CyRkW5V3.js → id-CBC-Hup3.js} +1 -1
  21. package/web/dist/assets/{it-Dcn0n8hB.js → it-sk9K0Y5L.js} +1 -1
  22. package/web/dist/assets/{main-n0417Eq4.js → main-ByEHJWGn.js} +2 -2
  23. package/web/dist/assets/{nl-DsWcNE-u.js → nl-DioO6nqY.js} +1 -1
  24. package/web/dist/assets/{ru-BQDAU-Ne.js → ru-o2SYLX7L.js} +1 -1
  25. package/web/dist/assets/{sv-hSwv-nee.js → sv-CCey7ex0.js} +1 -1
  26. package/web/dist/assets/{uk-DsncKZOV.js → uk-C2gHK98V.js} +1 -1
  27. package/web/dist/assets/{vi-CQscYJCZ.js → vi-B6AG6Gxz.js} +1 -1
  28. package/web/dist/assets/{zh-Br65nkI8.js → zh-Cnjg8Tp_.js} +1 -1
  29. package/web/dist/index.html +1 -1
  30. package/web/dist/sw.js +19 -19
package/dist/server.d.ts CHANGED
@@ -26,7 +26,7 @@ import { ListenerStore } from "./library-user.ts";
26
26
  import { Importer } from "./handoff.ts";
27
27
  import { AiRadio } from "./ai-radio.ts";
28
28
  import { ListenerShares } from "./listener-shares.ts";
29
- import { Support } from "./support.ts";
29
+ import { Support, SupportSubscriptions } from "./support.ts";
30
30
  import { PeerGuides } from "./station-guide.ts";
31
31
  import { StationRunner } from "./station-runner.ts";
32
32
  import { StationStore, type Station } from "./stations.ts";
@@ -807,6 +807,8 @@ export interface HandlerOptions {
807
807
  translationPasses?: TranslationPasses;
808
808
  /** Payments to copyright holders nixamp.com collects for (support.ts). */
809
809
  support?: Support;
810
+ /** Monthly support, run by nixamp.com (support.ts). */
811
+ supportSubscriptions?: SupportSubscriptions;
810
812
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
811
813
  coinpayWebhookSecret?: string;
812
814
  sharedTranslations?: SharedTranslations;
package/dist/server.js CHANGED
@@ -64,7 +64,7 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.js";
64
64
  import { AiRadio } from "./ai-radio.js";
65
65
  import { ListenerShares } from "./listener-shares.js";
66
66
  import { HolderStore, TagReader, factsFor } from "./track-facts.js";
67
- import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.js";
67
+ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions, supportHoldersFrom } from "./support.js";
68
68
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
69
69
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide } from "./station-guide.js";
70
70
  import { StationRunner } from "./station-runner.js";
@@ -2164,7 +2164,7 @@ export function createHandler(engine, options) {
2164
2164
  // What is on, as MCP, for anybody.
2165
2165
  path !== "/mcp/public" &&
2166
2166
  // Supporting a copyright holder is for anybody listening.
2167
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2167
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2168
2168
  // A station's short link, which sends the browser to wherever it plays.
2169
2169
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2170
2170
  !STATION_PAGE.test(path) &&
@@ -2231,6 +2231,22 @@ export function createHandler(engine, options) {
2231
2231
  * in the directory). It sends the browser to the station's own page, on
2232
2232
  * the server that plays it. Nothing private or unlisted is ever found.
2233
2233
  */
2234
+ /* A monthly support's cancel link, from any of its emails. */
2235
+ if (path === "/support/cancel" && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2236
+ let words;
2237
+ try {
2238
+ const done = await options.supportSubscriptions.cancel(url.searchParams.get("s") ?? "", url.searchParams.get("t") ?? "");
2239
+ const name = options.support?.holder(done.holder)?.name ?? done.holder;
2240
+ words = `Your monthly support for ${name} is cancelled. No more payment links will be sent.`;
2241
+ }
2242
+ catch (error) {
2243
+ words = error instanceof SupportError ? error.message : "That did not work; try the link again.";
2244
+ }
2245
+ const safe = words.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
2246
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer" });
2247
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2248
+ return;
2249
+ }
2234
2250
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2235
2251
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2236
2252
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -2269,7 +2285,14 @@ export function createHandler(engine, options) {
2269
2285
  catch {
2270
2286
  body = {};
2271
2287
  }
2272
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2288
+ if (body["monthly"] === true) {
2289
+ if (!options.supportSubscriptions)
2290
+ throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
2291
+ json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
2292
+ }
2293
+ else {
2294
+ json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2295
+ }
2273
2296
  return;
2274
2297
  }
2275
2298
  json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
@@ -6752,6 +6775,15 @@ export async function serve(argv, version = "0.1.0") {
6752
6775
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
6753
6776
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
6754
6777
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
6778
+ // Monthly support: each month a new CoinPay payment and its link by email.
6779
+ const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
6780
+ db: pool,
6781
+ support: supportPayments,
6782
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
6783
+ site: nixampSite,
6784
+ log: (line) => console.log(line),
6785
+ }) : undefined;
6786
+ supportMonthly?.startSchedule();
6755
6787
  if (translationPasses)
6756
6788
  void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
6757
6789
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
@@ -7301,6 +7333,7 @@ export async function serve(argv, version = "0.1.0") {
7301
7333
  ...(liveVoice ? { liveVoice } : {}),
7302
7334
  ...(translationPasses ? { translationPasses } : {}),
7303
7335
  ...(supportPayments ? { support: supportPayments } : {}),
7336
+ ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7304
7337
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7305
7338
  ...(sharedTranslations ? { sharedTranslations } : {}),
7306
7339
  ...(translator ? { translator } : {}),
@@ -1,3 +1,12 @@
1
+ /**
2
+ * A card's now-playing line from tags as they come: links pasted into a title
3
+ * (archive.org films carry their item URL) go, and "A;B" artists read "A, B".
4
+ * The page's script carries this same function, so both renders agree.
5
+ */
6
+ export declare function nowLine(now: {
7
+ title: string;
8
+ artist: string;
9
+ } | null | undefined): string;
1
10
  export interface GuideEntry {
2
11
  id?: string;
3
12
  name: string;
@@ -19,7 +28,7 @@ export interface GuideEntry {
19
28
  * /n/<name>), and for a crawler. Everything from a station is escaped.
20
29
  */
21
30
  export declare function renderGuide(entries: GuideEntry[], apiBase?: string): string;
22
- export declare const GUIDE_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Live \u00B7 stations</title>\n<meta name=\"description\" content=\"Radio and TV stations, live now: AI-composed music, tracks shared by listeners, and more.\">\n<link rel=\"icon\" href=\"data:,\">\n<style>\n:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#a1460b}\n@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#f0a35e}}\n*{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}\nmain{max-width:880px;margin:0 auto;padding:40px 16px}\nh1{margin:0 0 4px;font-size:1.8rem}p.lead{color:var(--muted);margin:0 0 24px}\nh2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:28px 0 10px}\n.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}\n.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}\n.card:hover,.card:focus-within{border-color:var(--accent)}\na.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}\n.name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}\n.live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}\nbutton.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}\nbutton.report:hover{color:var(--warn)}\n#share{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-top:12px}\n#share label{display:block;font-size:.9rem;margin:10px 0 4px;color:var(--muted)}\n#share input[type=url],#share input[type=text],#share select{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit}\n#share .agree{display:flex;gap:8px;align-items:flex-start;color:var(--fg);margin-top:12px}\n#share button[type=submit]{margin-top:14px;background:var(--accent);color:#fff;border:0;border-radius:8px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}\n#share .or{color:var(--muted);font-size:.85rem;margin:8px 0 0}\n#status{margin-top:10px;font-size:.9rem;min-height:1.4em}\nfooter{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}\n</style>\n</head>\n<body>\n<main>\n <h1>Live</h1>\n <p class=\"lead\">Radio and TV, on the air now. Pick one and press play.</p>\n <h2 id=\"ai-title\" hidden>AI Radio</h2>\n <div id=\"ai\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"shared-title\" hidden>Shared by listeners</h2>\n <div id=\"shared\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"radio-title\" hidden>Radio</h2>\n <div id=\"radio\" class=\"grid\" aria-live=\"polite\"></div>\n <h2 id=\"tv-title\" hidden>TV</h2>\n <div id=\"tv\" class=\"grid\" aria-live=\"polite\"></div>\n <p id=\"empty\" class=\"empty\">Nothing is on the air here yet.</p>\n <h2 id=\"share-title\" hidden>Share a track</h2>\n <form id=\"share\" hidden>\n <label for=\"url\">A link to an MP3</label>\n <input id=\"url\" name=\"url\" type=\"url\" placeholder=\"https://example.com/my-song.mp3\" inputmode=\"url\">\n <p class=\"or\">or upload one (up to <span id=\"max\">60 MB</span>)</p>\n <input id=\"file\" name=\"file\" type=\"file\" accept=\"audio/*,.mp3,.ogg,.flac,.m4a,.wav\">\n <label for=\"basis\">Why it may be broadcast</label>\n <select id=\"basis\" name=\"basis\" required>\n <option value=\"owned\">I made it</option>\n <option value=\"permission\">I have the artist's permission</option>\n <option value=\"licensed\">It is licensed for broadcast</option>\n <option value=\"creative-commons\">It is Creative Commons (allows sharing)</option>\n <option value=\"public-domain\">It is in the public domain</option>\n </select>\n <label for=\"note\">Anything we should know (licence, link to the artist)</label>\n <input id=\"note\" name=\"note\" type=\"text\" maxlength=\"300\">\n <label for=\"by\">Credit me as (optional)</label>\n <input id=\"by\" name=\"by\" type=\"text\" maxlength=\"60\">\n <label class=\"agree\"><input type=\"checkbox\" name=\"agree\" value=\"1\"> I have the right to share this for broadcast here. AI files it under a genre and checks it before it airs; anything can be reported off the air.</label>\n <button type=\"submit\">Share it</button>\n <div id=\"status\" role=\"status\" aria-live=\"polite\"></div>\n </form>\n <footer>Played by <a href=\"https://nixamp.com\">nixamp</a>, on the Moshpit.</footer>\n</main>\n<script>\n(async () => {\n const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };\n const say = (text) => { document.querySelector(\"#status\").textContent = text; };\n // This server's own address: through a gateway (pit.moshcode.sh/n/...) the\n // page's origin is not the server's, and a relative fetch would ask the gateway.\n const API = document.body.dataset.api || \"\";\n const report = async (s, button) => {\n // Click twice: a sandboxed page (the gateway's) cannot open confirm().\n if (button.dataset.armed !== \"1\") { button.dataset.armed = \"1\"; button.textContent = \"Click again to take it off the air\"; return; }\n button.disabled = true;\n try {\n const r = await fetch(new URL(s.page).origin + \"/api/v1/share/report\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ station: s.id }) });\n const body = await r.json();\n button.textContent = body.reported ? \"Reported: off the air\" : \"Nothing shared is playing\";\n } catch { button.textContent = \"Could not report; try again\"; button.disabled = false; }\n };\n const draw = (stations) => {\n for (const group of [\"ai\", \"shared\", \"radio\", \"tv\"]) {\n const grid = document.querySelector(\"#\" + group);\n grid.replaceChildren();\n const list = stations.filter((s) => (s.group || (s.kind === \"video\" ? \"tv\" : \"radio\")) === group);\n document.querySelector(\"#\" + group + \"-title\").hidden = list.length === 0;\n for (const s of list) {\n const card = el(\"div\", \"card\");\n const a = el(\"a\", \"station\");\n a.href = s.page;\n a.append(el(\"div\", \"live\", s.state === \"live\" ? \"\u25CF LIVE\" : \"off the air\"), el(\"div\", \"name\", s.name),\n el(\"div\", \"now\", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(\" \u2014 \") : \"\"));\n card.append(a);\n if (group === \"shared\") {\n const b = el(\"button\", \"report\", \"Report this track\");\n b.type = \"button\";\n b.addEventListener(\"click\", () => report(s, b));\n card.append(b);\n }\n grid.append(card);\n }\n }\n document.querySelector(\"#empty\").hidden = stations.length > 0;\n };\n const load = async () => {\n try {\n const r = await fetch(API + \"/api/v1/stations/guide\", { cache: \"no-store\" });\n if (r.ok && (r.headers.get(\"content-type\") || \"\").includes(\"json\")) draw((await r.json()).stations);\n } catch {}\n };\n const watch = async (status) => {\n for (let i = 0; i < 120; i++) {\n await new Promise((done) => setTimeout(done, 3000));\n try {\n const share = (await (await fetch(API + status, { cache: \"no-store\" })).json()).share;\n if (!share) return;\n const words = { fetching: \"Fetching it\u2026\", checking: \"Listening to it and checking it\u2026\", held: \"Waiting for the owner to look at it\", approved: \"On the air\", rejected: \"Not accepted\", failed: \"Could not use it\", withdrawn: \"Withdrawn\", reported: \"Reported\" };\n say((words[share.state] || share.state) + (share.genre ? \" \u00B7 \" + share.genre : \"\") + (share.reason ? \" \u2014 \" + share.reason : \"\"));\n if (![\"fetching\", \"checking\"].includes(share.state)) { load(); return; }\n } catch {}\n }\n };\n const form = document.querySelector(\"#share\");\n try {\n const info = await (await fetch(API + \"/api/v1/share\", { cache: \"no-store\" })).json();\n if (info.open) {\n document.querySelector(\"#share-title\").hidden = false;\n form.hidden = false;\n document.querySelector(\"#max\").textContent = info.maxMb + \" MB\";\n }\n } catch {}\n form.addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n const data = new FormData(form);\n const file = data.get(\"file\");\n const link = String(data.get(\"url\") || \"\").trim();\n const fields = { basis: String(data.get(\"basis\") || \"\"), note: String(data.get(\"note\") || \"\"), by: String(data.get(\"by\") || \"\"), agree: data.get(\"agree\") ? \"1\" : \"\" };\n if (!fields.agree) { say(\"Tick the box to confirm you have the right to share it.\"); return; }\n if (!link && !(file && file.size)) { say(\"Paste a link to an MP3, or choose a file.\"); return; }\n say(file && file.size && !link ? \"Uploading\u2026\" : \"Sending\u2026\");\n try {\n const r = file && file.size && !link\n ? await fetch(API + \"/api/v1/share/upload?\" + new URLSearchParams({ ...fields, name: file.name }), { method: \"PUT\", headers: { \"content-type\": file.type || \"audio/mpeg\" }, body: file })\n : await fetch(API + \"/api/v1/share\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ ...fields, agree: true, url: link }) });\n const body = await r.json();\n if (!r.ok) { say(body.error || \"That did not work.\"); return; }\n form.reset();\n say(\"Got it. Checking it now\u2026\");\n watch(body.status);\n } catch { say(\"Could not reach the station; try again.\"); }\n });\n await load();\n setInterval(load, 15000);\n})();\n</script>\n</body>\n</html>\n";
31
+ export declare const GUIDE_HTML: string;
23
32
  export declare const GUIDE_CSP: string;
24
33
  /**
25
34
  * The hosts whose front page is the guide: `guideHosts` in config.json
@@ -19,7 +19,7 @@ h2{font-size:1rem;letter-spacing:.06em;text-transform:uppercase;color:var(--mute
19
19
  .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;position:relative}
20
20
  .card:hover,.card:focus-within{border-color:var(--accent)}
21
21
  a.station{color:inherit;text-decoration:none;display:block}a.station:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:6px}
22
- .name{font-weight:700;font-size:1.05rem}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em}
22
+ .name{font-weight:700;font-size:1.05rem;overflow-wrap:anywhere}.now{color:var(--muted);font-size:.9rem;margin-top:6px;min-height:1.4em;overflow-wrap:anywhere}
23
23
  .live{color:var(--live);font-size:.8rem;font-weight:700}.empty{color:var(--muted)}
24
24
  button.report{margin-top:10px;background:none;border:0;padding:0;color:var(--muted);font:inherit;font-size:.8rem;text-decoration:underline;cursor:pointer}
25
25
  button.report:hover{color:var(--warn)}
@@ -32,8 +32,21 @@ button.report:hover{color:var(--warn)}
32
32
  #status{margin-top:10px;font-size:.9rem;min-height:1.4em}
33
33
  footer{margin-top:40px;color:var(--muted);font-size:.85rem}footer a{color:inherit}
34
34
  `;
35
+ /**
36
+ * A card's now-playing line from tags as they come: links pasted into a title
37
+ * (archive.org films carry their item URL) go, and "A;B" artists read "A, B".
38
+ * The page's script carries this same function, so both renders agree.
39
+ */
40
+ export function nowLine(now) {
41
+ if (!now)
42
+ return "";
43
+ const title = String(now.title || "").replace(/\s*[-–—|]?\s*https?:\/\/\S+/g, "").trim();
44
+ const artist = String(now.artist || "").split(/\s*;\s*/).filter(Boolean).join(", ");
45
+ return [title, artist].filter(Boolean).join(" — ");
46
+ }
35
47
  const SCRIPT = `
36
48
  (async () => {
49
+ const nowLine = ${nowLine.toString()};
37
50
  const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text) n.textContent = text; return n; };
38
51
  const say = (text) => { document.querySelector("#status").textContent = text; };
39
52
  // This server's own address: through a gateway (pit.moshcode.sh/n/...) the
@@ -60,7 +73,7 @@ const SCRIPT = `
60
73
  const a = el("a", "station");
61
74
  a.href = s.page;
62
75
  a.append(el("div", "live", s.state === "live" ? "● LIVE" : "off the air"), el("div", "name", s.name),
63
- el("div", "now", s.nowPlaying ? [s.nowPlaying.title, s.nowPlaying.artist].filter(Boolean).join(" — ") : ""));
76
+ el("div", "now", nowLine(s.nowPlaying)));
64
77
  card.append(a);
65
78
  if (group === "shared") {
66
79
  const b = el("button", "report", "Report this track");
@@ -138,7 +151,7 @@ export function renderGuide(entries, apiBase = "") {
138
151
  const groupOf = (one) => one.group ?? (one.kind === "video" ? "tv" : "radio");
139
152
  const cards = (group) => entries
140
153
  .filter((one) => groupOf(one) === group && /^https?:\/\//.test(one.page))
141
- .map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${one.nowPlaying ? escape([one.nowPlaying.title, one.nowPlaying.artist].filter(Boolean).join(" — ")) : ""}</div></a></div>`)
154
+ .map((one) => `<div class="card"><a class="station" href="${escape(one.page)}"><div class="live">${one.state === "live" ? "● LIVE" : "off the air"}</div><div class="name">${escape(one.name)}</div><div class="now">${escape(nowLine(one.nowPlaying))}</div></a></div>`)
142
155
  .join("");
143
156
  let page = GUIDE_HTML;
144
157
  for (const group of GROUPS) {
@@ -202,7 +215,7 @@ export const GUIDE_HTML = `<!doctype html>
202
215
  </html>
203
216
  `;
204
217
  export const GUIDE_CSP = [
205
- "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self' https:",
218
+ "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src 'self' ${hash(STYLE)}`, "connect-src 'self' https:",
206
219
  "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
207
220
  ].join("; ");
208
221
  /**
package/dist/support.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare class Support {
20
20
  fetcher?: typeof fetch;
21
21
  now?: () => number;
22
22
  });
23
+ /** For a subscription's renewals, which ask CoinPay about their payments. */
24
+ get key(): string;
23
25
  holder(id: string): SupportHolder | undefined;
24
26
  get enabled(): boolean;
25
27
  private provider;
@@ -35,9 +37,72 @@ export declare class Support {
35
37
  coin?: unknown;
36
38
  station?: unknown;
37
39
  track?: unknown;
40
+ metadata?: Record<string, unknown>;
38
41
  }): Promise<{
39
42
  url: string;
43
+ paymentId: string;
40
44
  }>;
41
45
  }
42
46
  export declare const SUPPORT_PAGE: string;
43
47
  export declare const SUPPORT_CSP: string;
48
+ /** Postgres, as nixamp.com's pool offers it. */
49
+ export interface Db {
50
+ query: (text: string, params?: unknown[]) => Promise<{
51
+ rows: Array<Record<string, unknown>>;
52
+ }>;
53
+ }
54
+ /** One month on from a time, on the same day where the month has it. */
55
+ export declare function nextMonth(at: number): number;
56
+ /**
57
+ * Monthly support, run here rather than by CoinPay: CoinPay's only merchant
58
+ * subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
59
+ * A subscription is an email, an amount, a coin and a holder. Month one is
60
+ * paid at once on CoinPay's checkout; each month after, a new payment is made
61
+ * and its link emailed, with a one-click cancel. A month left unpaid ends it
62
+ * (lapsed) rather than emailing forever.
63
+ */
64
+ export declare class SupportSubscriptions {
65
+ private readonly options;
66
+ private schema;
67
+ private timer;
68
+ constructor(options: {
69
+ db: Db;
70
+ support: Support;
71
+ /** Resend; null leaves renewals unsent (and says so in the log). */
72
+ mail: {
73
+ apiKey: string;
74
+ from: string;
75
+ } | null;
76
+ site: string;
77
+ fetcher?: typeof fetch;
78
+ now?: () => number;
79
+ log?: (line: string) => void;
80
+ });
81
+ private now;
82
+ private ready;
83
+ /** Month one: a subscription and its first checkout. */
84
+ start(input: {
85
+ holder?: unknown;
86
+ amountUsd?: unknown;
87
+ coin?: unknown;
88
+ email?: unknown;
89
+ station?: unknown;
90
+ track?: unknown;
91
+ }): Promise<{
92
+ url: string;
93
+ id: string;
94
+ }>;
95
+ cancelLink(id: string, token: string): string;
96
+ cancel(id: string, token: string): Promise<{
97
+ canceled: boolean;
98
+ holder: string;
99
+ }>;
100
+ private paid;
101
+ /** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
102
+ renew(): Promise<{
103
+ renewed: number;
104
+ lapsed: number;
105
+ }>;
106
+ startSchedule(everyMs?: number): void;
107
+ private mail;
108
+ }
package/dist/support.js CHANGED
@@ -40,6 +40,10 @@ export class Support {
40
40
  constructor(options) {
41
41
  this.options = options;
42
42
  }
43
+ /** For a subscription's renewals, which ask CoinPay about their payments. */
44
+ get key() {
45
+ return this.options.key;
46
+ }
43
47
  holder(id) {
44
48
  return this.options.holders.find((one) => one.id === id.toLowerCase());
45
49
  }
@@ -100,7 +104,7 @@ export class Support {
100
104
  currency: coin.toLowerCase(),
101
105
  payment_method: "crypto",
102
106
  description: `Support ${holder.name} on nixamp`,
103
- metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track) },
107
+ metadata: { app: "nixamp", product: "support", holder: holder.id, order, station: clean(input.station), track: clean(input.track), ...(input.metadata ?? {}) },
104
108
  redirect_url: `${this.options.site}/support/${holder.id}?thanks=1`,
105
109
  }),
106
110
  });
@@ -108,7 +112,7 @@ export class Support {
108
112
  const id = typeof payment?.id === "string" ? payment.id : "";
109
113
  if (!/^[A-Za-z0-9_-]{6,80}$/.test(id))
110
114
  throw new SupportError("The checkout did not start; nothing was charged. Try again.", 502, "provider");
111
- return { url: `https://coinpayportal.com/pay/${id}` };
115
+ return { url: `https://coinpayportal.com/pay/${id}`, paymentId: id };
112
116
  }
113
117
  }
114
118
  const STYLE = `
@@ -126,6 +130,8 @@ button.go{margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;b
126
130
  button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
127
131
  #status{min-height:1.4em;margin-top:12px;color:var(--muted)}
128
132
  .thanks{color:var(--accent);font-weight:700}
133
+ .often{display:flex;gap:8px;margin:0 0 14px}.often label{flex:1;margin:0;display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border:1px solid var(--line);border-radius:10px;color:var(--fg);cursor:pointer}
134
+ .often input{width:auto}
129
135
  small{display:block;margin-top:18px;color:var(--muted)}small a{color:inherit}
130
136
  `;
131
137
  const SCRIPT = `
@@ -141,6 +147,11 @@ const SCRIPT = `
141
147
  for (const o of document.querySelectorAll(".amounts button")) o.setAttribute("aria-pressed", String(o === b));
142
148
  });
143
149
  }
150
+ const monthly = () => document.querySelector("input[name=often]:checked").value === "monthly";
151
+ for (const r of document.querySelectorAll("input[name=often]")) r.addEventListener("change", () => {
152
+ $("#email-box").hidden = !monthly();
153
+ $("#go").textContent = monthly() ? "Pay the first month" : "Continue to payment";
154
+ });
144
155
  if (params.get("thanks")) { $("#status").className = "thanks"; $("#status").textContent = "Thank you. Your payment is on its way."; }
145
156
  try {
146
157
  const info = await (await fetch("/api/v1/support/" + encodeURIComponent(holder), { cache: "no-store" })).json();
@@ -157,7 +168,8 @@ const SCRIPT = `
157
168
  $("#status").textContent = "Starting the checkout…";
158
169
  try {
159
170
  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") || "" }) });
171
+ body: JSON.stringify({ amountUsd: usd, coin: $("#coin").value, station: params.get("station") || "", track: params.get("track") || "",
172
+ ...(monthly() ? { monthly: true, email: $("#email").value } : {}) }) });
161
173
  const body = await r.json();
162
174
  if (r.ok && body.url) { location.href = body.url; return; }
163
175
  $("#status").textContent = body.error || "That did not work.";
@@ -180,17 +192,22 @@ export const SUPPORT_PAGE = `<!doctype html>
180
192
  <main>
181
193
  <div class="card">
182
194
  <h1>Support <span id="who">the artist</span></h1>
183
- <p>Pay the copyright holder of the music you are listening to, directly. One payment, in crypto, through CoinPay.</p>
195
+ <p>Pay the copyright holder of the music you are listening to, directly, in crypto through CoinPay. Once, or every month.</p>
184
196
  <form id="form">
197
+ <div class="often"><label><input type="radio" name="often" value="once" checked> One time</label><label><input type="radio" name="often" value="monthly"> Monthly</label></div>
185
198
  <div class="amounts">${AMOUNTS.map((usd, at) => `<button type="button" data-usd="${usd}" aria-pressed="${at === 1}">$${usd}</button>`).join("")}</div>
186
199
  <label for="other">Or another amount (US$)</label>
187
200
  <input id="other" type="number" min="1" max="1000" step="1" inputmode="numeric">
201
+ <div id="email-box" hidden>
202
+ <label for="email">Email, for each month's payment link (and the cancel link)</label>
203
+ <input id="email" type="email" autocomplete="email" maxlength="254">
204
+ </div>
188
205
  <label for="coin">Pay with</label>
189
206
  <select id="coin"></select>
190
207
  <button id="go" class="go" type="submit">Continue to payment</button>
191
208
  <div id="status" role="status" aria-live="polite"></div>
192
209
  </form>
193
- <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Nothing recurs.</small>
210
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com, which pays the holder. Monthly: each month we email a new payment link; nothing is taken until you pay it, and every email has a one-click cancel.</small>
194
211
  </div>
195
212
  </main>
196
213
  <script>${SCRIPT}</script>
@@ -201,3 +218,168 @@ export const SUPPORT_CSP = [
201
218
  "default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "connect-src 'self'",
202
219
  "img-src 'self' data:", "base-uri 'none'", "form-action 'none'", "frame-ancestors 'none'",
203
220
  ].join("; ");
221
+ /** What CoinPay calls a payment that has been made. */
222
+ const PAID = new Set(["confirmed", "forwarding", "forwarded", "forwarding_failed", "completed"]);
223
+ const EMAIL = /^[^\s@<>"]{1,64}@[^\s@<>"]{1,190}\.[a-z]{2,24}$/i;
224
+ /** One month on from a time, on the same day where the month has it. */
225
+ export function nextMonth(at) {
226
+ const date = new Date(at);
227
+ const day = date.getUTCDate();
228
+ date.setUTCDate(1);
229
+ date.setUTCMonth(date.getUTCMonth() + 1);
230
+ const last = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0)).getUTCDate();
231
+ date.setUTCDate(Math.min(day, last));
232
+ return date.getTime();
233
+ }
234
+ /**
235
+ * Monthly support, run here rather than by CoinPay: CoinPay's only merchant
236
+ * subscriptions are Stripe's, and its recurring invoices refuse a scoped key.
237
+ * A subscription is an email, an amount, a coin and a holder. Month one is
238
+ * paid at once on CoinPay's checkout; each month after, a new payment is made
239
+ * and its link emailed, with a one-click cancel. A month left unpaid ends it
240
+ * (lapsed) rather than emailing forever.
241
+ */
242
+ export class SupportSubscriptions {
243
+ options;
244
+ schema = null;
245
+ timer = null;
246
+ constructor(options) {
247
+ this.options = options;
248
+ }
249
+ now() {
250
+ return this.options.now?.() ?? Date.now();
251
+ }
252
+ ready() {
253
+ this.schema ??= (async () => {
254
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS support_subscriptions (
255
+ id TEXT PRIMARY KEY, token TEXT NOT NULL, holder TEXT NOT NULL, email TEXT NOT NULL,
256
+ amount_cents INTEGER NOT NULL, coin TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
257
+ cycles INTEGER NOT NULL DEFAULT 0, last_payment_id TEXT, next_due TIMESTAMPTZ NOT NULL,
258
+ station TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, canceled_at TIMESTAMPTZ)`);
259
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS support_subscriptions_due ON support_subscriptions (status, next_due)");
260
+ })().catch((error) => { this.schema = null; throw error; });
261
+ return this.schema;
262
+ }
263
+ /** Month one: a subscription and its first checkout. */
264
+ async start(input) {
265
+ const email = typeof input.email === "string" ? input.email.trim().toLowerCase() : "";
266
+ if (!EMAIL.test(email))
267
+ throw new SupportError("Give an email for the monthly payment links.", 400, "invalid_email");
268
+ const holder = typeof input.holder === "string" ? this.options.support.holder(input.holder) : undefined;
269
+ if (!holder)
270
+ throw new SupportError("Nobody by that name is supported here.", 404, "not_found");
271
+ await this.ready();
272
+ const live = await this.options.db.query("SELECT count(*)::int AS n FROM support_subscriptions WHERE email = $1 AND status = 'active'", [email]);
273
+ if (Number(live.rows[0]?.["n"] ?? 0) >= 10)
274
+ throw new SupportError("That email already has ten monthly supports.", 409, "too_many");
275
+ const id = randomUUID();
276
+ const token = randomUUID().replace(/-/g, "");
277
+ const checkout = await this.options.support.checkout({ ...input, holder: holder.id, metadata: { subscription: id, cycle: 1 } });
278
+ const now = this.now();
279
+ await this.options.db.query(`INSERT INTO support_subscriptions (id, token, holder, email, amount_cents, coin, cycles, last_payment_id, next_due, station, created_at)
280
+ VALUES ($1,$2,$3,$4,$5,$6,1,$7,$8,$9,$10)`, [id, token, holder.id, email, Math.round(Number(input.amountUsd) * 100), String(input.coin).toUpperCase(), checkout.paymentId, new Date(nextMonth(now)), typeof input.station === "string" ? input.station.slice(0, 20) : "", new Date(now)]);
281
+ await this.mail(email, `Your monthly support for ${holder.name}`, [
282
+ `Thank you for supporting ${holder.name} on nixamp, $${Number(input.amountUsd).toFixed(2)} a month in ${String(input.coin).toUpperCase()}.`,
283
+ `This month: ${checkout.url}`,
284
+ `Each month we email you a new payment link like this one. Nothing is taken without you paying it.`,
285
+ `Stop at any time: ${this.cancelLink(id, token)}`,
286
+ ]);
287
+ return { url: checkout.url, id };
288
+ }
289
+ cancelLink(id, token) {
290
+ return `${this.options.site}/support/cancel?s=${encodeURIComponent(id)}&t=${encodeURIComponent(token)}`;
291
+ }
292
+ async cancel(id, token) {
293
+ await this.ready();
294
+ const found = await this.options.db.query("UPDATE support_subscriptions SET status = 'canceled', canceled_at = $3 WHERE id = $1 AND token = $2 AND status <> 'canceled' RETURNING holder", [id, token, new Date(this.now())]);
295
+ const row = found.rows[0];
296
+ if (!row) {
297
+ const known = await this.options.db.query("SELECT holder FROM support_subscriptions WHERE id = $1 AND token = $2", [id, token]);
298
+ if (!known.rows[0])
299
+ throw new SupportError("That cancel link is not one of ours.", 404, "not_found");
300
+ return { canceled: true, holder: String(known.rows[0]["holder"]) };
301
+ }
302
+ return { canceled: true, holder: String(row["holder"]) };
303
+ }
304
+ async paid(paymentId) {
305
+ try {
306
+ const response = await (this.options.fetcher ?? fetch)(`https://coinpayportal.com/api/payments/${encodeURIComponent(paymentId)}`, {
307
+ signal: AbortSignal.timeout(15_000),
308
+ headers: { authorization: `Bearer ${this.options.support.key}` },
309
+ });
310
+ if (!response.ok)
311
+ return null;
312
+ const body = (await response.json());
313
+ return PAID.has(String(body.payment?.status ?? ""));
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ }
319
+ /** Every subscription now due: a new month's payment and its link, or lapsed when last month went unpaid. */
320
+ async renew() {
321
+ await this.ready();
322
+ const due = await this.options.db.query("SELECT * FROM support_subscriptions WHERE status = 'active' AND next_due <= $1 ORDER BY next_due LIMIT 200", [new Date(this.now())]);
323
+ let renewed = 0;
324
+ let lapsed = 0;
325
+ for (const row of due.rows) {
326
+ const id = String(row["id"]);
327
+ const last = String(row["last_payment_id"] ?? "");
328
+ const paid = last ? await this.paid(last) : false;
329
+ if (paid === null)
330
+ continue; // CoinPay did not answer: ask again next time.
331
+ const holder = this.options.support.holder(String(row["holder"]));
332
+ if (!paid || !holder) {
333
+ await this.options.db.query("UPDATE support_subscriptions SET status = 'lapsed' WHERE id = $1 AND status = 'active'", [id]);
334
+ lapsed += 1;
335
+ if (holder)
336
+ await this.mail(String(row["email"]), `Your monthly support for ${holder.name} has stopped`, [
337
+ `Last month's payment was not made, so we have stopped sending payment links. Nothing more will be asked.`,
338
+ `Start again any time: ${this.options.site}/support/${holder.id}`,
339
+ ]);
340
+ continue;
341
+ }
342
+ const amountUsd = Number(row["amount_cents"]) / 100;
343
+ const cycle = Number(row["cycles"]) + 1;
344
+ let checkout;
345
+ try {
346
+ checkout = await this.options.support.checkout({ holder: holder.id, amountUsd, coin: String(row["coin"]), station: String(row["station"] ?? ""), metadata: { subscription: id, cycle } });
347
+ }
348
+ catch (error) {
349
+ this.options.log?.(` support: renewal ${id} could not start a payment: ${error.message}`);
350
+ continue;
351
+ }
352
+ await this.options.db.query("UPDATE support_subscriptions SET cycles = $2, last_payment_id = $3, next_due = $4 WHERE id = $1", [id, cycle, checkout.paymentId, new Date(nextMonth(new Date(String(row["next_due"])).getTime()))]);
353
+ await this.mail(String(row["email"]), `This month's support for ${holder.name}`, [
354
+ `Your monthly support for ${holder.name}: $${amountUsd.toFixed(2)} in ${String(row["coin"])}.`,
355
+ `Pay this month: ${checkout.url}`,
356
+ `Stop at any time: ${this.cancelLink(id, String(row["token"]))}`,
357
+ ]);
358
+ renewed += 1;
359
+ }
360
+ return { renewed, lapsed };
361
+ }
362
+ startSchedule(everyMs = 3600_000) {
363
+ if (this.timer)
364
+ return;
365
+ this.timer = setInterval(() => void this.renew().catch((error) => this.options.log?.(` support: renewals failed: ${error.message}`)), everyMs);
366
+ this.timer.unref?.();
367
+ }
368
+ async mail(to, subject, lines) {
369
+ if (!this.options.mail) {
370
+ this.options.log?.(` support: no RESEND_API_KEY, so "${subject}" to ${to} was not sent`);
371
+ return;
372
+ }
373
+ try {
374
+ await (this.options.fetcher ?? fetch)("https://api.resend.com/emails", {
375
+ method: "POST",
376
+ signal: AbortSignal.timeout(15_000),
377
+ headers: { authorization: `Bearer ${this.options.mail.apiKey}`, "content-type": "application/json" },
378
+ body: JSON.stringify({ from: this.options.mail.from, to: [to], subject, text: `${lines.join("\n\n")}\n\nnixamp.com` }),
379
+ });
380
+ }
381
+ catch (error) {
382
+ this.options.log?.(` support: mail to ${to} failed: ${error.message}`);
383
+ }
384
+ }
385
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/server.ts CHANGED
@@ -81,7 +81,7 @@ import { handleAiRoutes, isPublicSharePath } from "./ai-routes.ts";
81
81
  import { AiRadio } from "./ai-radio.ts";
82
82
  import { ListenerShares } from "./listener-shares.ts";
83
83
  import { HolderStore, TagReader, factsFor } from "./track-facts.ts";
84
- import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, supportHoldersFrom } from "./support.ts";
84
+ import { SUPPORT_CSP, SUPPORT_PAGE, Support, SupportError, SupportSubscriptions, supportHoldersFrom } from "./support.ts";
85
85
  import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
86
86
  import { GUIDE_CSP, guideHosts, guidePeers, peerBase, PeerGuides, renderGuide, type GuideEntry } from "./station-guide.ts";
87
87
  import { StationRunner } from "./station-runner.ts";
@@ -1924,6 +1924,8 @@ export interface HandlerOptions {
1924
1924
  translationPasses?: TranslationPasses;
1925
1925
  /** Payments to copyright holders nixamp.com collects for (support.ts). */
1926
1926
  support?: Support;
1927
+ /** Monthly support, run by nixamp.com (support.ts). */
1928
+ supportSubscriptions?: SupportSubscriptions;
1927
1929
  /** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
1928
1930
  coinpayWebhookSecret?: string;
1929
1931
  sharedTranslations?: SharedTranslations;
@@ -2698,7 +2700,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2698
2700
  // What is on, as MCP, for anybody.
2699
2701
  path !== "/mcp/public" &&
2700
2702
  // Supporting a copyright holder is for anybody listening.
2701
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2703
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2702
2704
  // A station's short link, which sends the browser to wherever it plays.
2703
2705
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2704
2706
  !STATION_PAGE.test(path) &&
@@ -2765,6 +2767,22 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2765
2767
  * in the directory). It sends the browser to the station's own page, on
2766
2768
  * the server that plays it. Nothing private or unlisted is ever found.
2767
2769
  */
2770
+ /* A monthly support's cancel link, from any of its emails. */
2771
+ if (path === "/support/cancel" && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2772
+ let words: string;
2773
+ try {
2774
+ const done = await options.supportSubscriptions.cancel(url.searchParams.get("s") ?? "", url.searchParams.get("t") ?? "");
2775
+ const name = options.support?.holder(done.holder)?.name ?? done.holder;
2776
+ words = `Your monthly support for ${name} is cancelled. No more payment links will be sent.`;
2777
+ } catch (error) {
2778
+ words = error instanceof SupportError ? error.message : "That did not work; try the link again.";
2779
+ }
2780
+ const safe = words.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
2781
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer" });
2782
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe}</p><p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2783
+ return;
2784
+ }
2785
+
2768
2786
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2769
2787
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2770
2788
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -2798,7 +2816,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2798
2816
  }
2799
2817
  let body: Record<string, unknown> = {};
2800
2818
  try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
2801
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2819
+ if (body["monthly"] === true) {
2820
+ if (!options.supportSubscriptions) throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
2821
+ json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
2822
+ } else {
2823
+ json(response, 200, await support.checkout({ ...body, holder: holder.id }));
2824
+ }
2802
2825
  return;
2803
2826
  }
2804
2827
  json(response, 405, { error: "GET or POST", code: "method_not_allowed" });
@@ -7234,6 +7257,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7234
7257
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
7235
7258
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
7236
7259
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
7260
+ // Monthly support: each month a new CoinPay payment and its link by email.
7261
+ const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
7262
+ db: pool as unknown as import("./support.ts").Db,
7263
+ support: supportPayments,
7264
+ mail: process.env["RESEND_API_KEY"] ? { apiKey: process.env["RESEND_API_KEY"], from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>" } : null,
7265
+ site: nixampSite,
7266
+ log: (line) => console.log(line),
7267
+ }) : undefined;
7268
+ supportMonthly?.startSchedule();
7237
7269
  if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
7238
7270
  const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
7239
7271
  ...(pool ? { db: pool } : {}),
@@ -7787,6 +7819,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7787
7819
  ...(liveVoice ? { liveVoice } : {}),
7788
7820
  ...(translationPasses ? { translationPasses } : {}),
7789
7821
  ...(supportPayments ? { support: supportPayments } : {}),
7822
+ ...(supportMonthly ? { supportSubscriptions: supportMonthly } : {}),
7790
7823
  ...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
7791
7824
  ...(sharedTranslations ? { sharedTranslations } : {}),
7792
7825
  ...(translator ? { translator } : {}),