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.
- package/dist/server.d.ts +3 -1
- package/dist/server.js +36 -3
- package/dist/station-guide.d.ts +10 -1
- package/dist/station-guide.js +17 -4
- package/dist/support.d.ts +65 -0
- package/dist/support.js +187 -5
- package/package.json +1 -1
- package/src/server.ts +36 -3
- package/src/station-guide.ts +17 -4
- package/src/support.ts +201 -6
- package/web/dist/assets/{ar-BSokqBTY.js → ar-S4rqddex.js} +1 -1
- package/web/dist/assets/{cs-B9taw8Ps.js → cs-BShF2rip.js} +1 -1
- package/web/dist/assets/{da-vmuHyz8I.js → da-BO87iZOE.js} +1 -1
- package/web/dist/assets/{de-CWV-UGto.js → de-CUANqdKo.js} +1 -1
- package/web/dist/assets/{es-cjyWEn7s.js → es-BdUyDUC0.js} +1 -1
- package/web/dist/assets/{fi-DE6zUbIW.js → fi-BBBq-Bd8.js} +1 -1
- package/web/dist/assets/{fr-DOKMh3Nf.js → fr-DqZ3HEY8.js} +1 -1
- package/web/dist/assets/{hi-DDmREWrp.js → hi-BBMF8sCD.js} +1 -1
- package/web/dist/assets/{hu-D-gj8zTM.js → hu-D1LEEcho.js} +1 -1
- package/web/dist/assets/{id-CyRkW5V3.js → id-CBC-Hup3.js} +1 -1
- package/web/dist/assets/{it-Dcn0n8hB.js → it-sk9K0Y5L.js} +1 -1
- package/web/dist/assets/{main-n0417Eq4.js → main-ByEHJWGn.js} +2 -2
- package/web/dist/assets/{nl-DsWcNE-u.js → nl-DioO6nqY.js} +1 -1
- package/web/dist/assets/{ru-BQDAU-Ne.js → ru-o2SYLX7L.js} +1 -1
- package/web/dist/assets/{sv-hSwv-nee.js → sv-CCey7ex0.js} +1 -1
- package/web/dist/assets/{uk-DsncKZOV.js → uk-C2gHK98V.js} +1 -1
- package/web/dist/assets/{vi-CQscYJCZ.js → vi-B6AG6Gxz.js} +1 -1
- package/web/dist/assets/{zh-Br65nkI8.js → zh-Cnjg8Tp_.js} +1 -1
- package/web/dist/index.html +1 -1
- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
|
|
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 } : {}),
|
package/dist/station-guide.d.ts
CHANGED
|
@@ -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
|
package/dist/station-guide.js
CHANGED
|
@@ -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
|
|
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">${
|
|
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
|
|
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.
|
|
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
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
|
|
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 } : {}),
|