nixamp 0.67.0 → 0.67.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +17 -4
- package/dist/ai-label.js +39 -9
- package/dist/license-routes.js +6 -13
- package/dist/music-license.js +4 -3
- package/dist/music-store.js +4 -3
- package/dist/server.js +76 -15
- package/dist/store-routes.js +6 -14
- package/dist/support.d.ts +77 -8
- package/dist/support.js +229 -93
- package/package.json +2 -2
- package/src/ai-label.ts +45 -10
- package/src/license-routes.ts +6 -13
- package/src/music-license.ts +4 -3
- package/src/music-store.ts +4 -3
- package/src/server.ts +59 -9
- package/src/store-routes.ts +6 -14
- package/src/support.ts +227 -90
- package/web/dist/assets/{ar-Dw3s9ADu.js → ar-DxGw3Rs5.js} +1 -1
- package/web/dist/assets/{cs-CzDFyr4i.js → cs-CvhFUJ-M.js} +1 -1
- package/web/dist/assets/{da-DCTQgBaR.js → da-DNNaWmG1.js} +1 -1
- package/web/dist/assets/{de-bRuy-6md.js → de-Bd73nJDj.js} +1 -1
- package/web/dist/assets/{es-iHd4cFjq.js → es-kql7koI9.js} +1 -1
- package/web/dist/assets/{fi-BQgHm0iD.js → fi-DOSVk45m.js} +1 -1
- package/web/dist/assets/{fr-Bx2XC-ON.js → fr-C-PWAVGY.js} +1 -1
- package/web/dist/assets/{hi-Cq8IDrah.js → hi-DSccyns6.js} +1 -1
- package/web/dist/assets/{hu-C3mzcLs_.js → hu-nhOx8KN8.js} +1 -1
- package/web/dist/assets/{id-DddaqO3C.js → id-B_7XcFMO.js} +1 -1
- package/web/dist/assets/{it-C8TvaS5w.js → it-DS_wAQLZ.js} +1 -1
- package/web/dist/assets/main-DBqNZR5i.js +22 -0
- package/web/dist/assets/{nl-C3LqPPhu.js → nl-E4kRa4hT.js} +1 -1
- package/web/dist/assets/{ru-DzAMilh-.js → ru-BBTDvHvj.js} +1 -1
- package/web/dist/assets/{sv-Ca1BMi0N.js → sv-eb2QkbPB.js} +1 -1
- package/web/dist/assets/{uk-D-CADfyO.js → uk-D8ewz76c.js} +1 -1
- package/web/dist/assets/{vi-CWeZdHAD.js → vi-DjxETiKX.js} +1 -1
- package/web/dist/assets/{zh-BvS0BRgy.js → zh-pjVIcKxb.js} +1 -1
- package/web/dist/index.html +2 -3
- package/web/dist/sw.js +19 -19
- package/web/dist/assets/main-BSOJX0Of.js +0 -22
package/src/license-routes.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
15
15
|
import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms, type License, type MusicLicenses, type Quote, type Tier } from "./music-license.ts";
|
|
16
|
-
import { SupportError } from "./support.ts";
|
|
16
|
+
import { SupportError, paidIn, payUrl } from "./support.ts";
|
|
17
17
|
|
|
18
18
|
const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
|
|
19
19
|
const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
|
|
@@ -74,14 +74,9 @@ const SCRIPT = `
|
|
|
74
74
|
if (!form) return;
|
|
75
75
|
const status = $("#status");
|
|
76
76
|
const pay = $("#pay");
|
|
77
|
-
const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price
|
|
77
|
+
const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price : "Pick a licence"; };
|
|
78
78
|
form.addEventListener("change", label);
|
|
79
79
|
label();
|
|
80
|
-
fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
|
|
81
|
-
const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
|
|
82
|
-
const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
|
|
83
|
-
$("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
|
|
84
|
-
}).catch(() => { status.textContent = "Payments are not available right now."; });
|
|
85
80
|
form.addEventListener("submit", async (event) => {
|
|
86
81
|
event.preventDefault();
|
|
87
82
|
pay.disabled = true;
|
|
@@ -90,7 +85,7 @@ const SCRIPT = `
|
|
|
90
85
|
try {
|
|
91
86
|
const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
|
|
92
87
|
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
|
|
93
|
-
projectUrl: data.get("projectUrl"), email: data.get("email"),
|
|
88
|
+
projectUrl: data.get("projectUrl"), email: data.get("email"), agree: data.get("agree") === "on" }) });
|
|
94
89
|
const body = await r.json();
|
|
95
90
|
if (!r.ok) throw new Error(body.error || "The checkout did not start.");
|
|
96
91
|
status.textContent = "Taking you to CoinPay…";
|
|
@@ -181,13 +176,11 @@ export async function handleLicenseRoutes(request: IncomingMessage, response: Se
|
|
|
181
176
|
<input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
|
|
182
177
|
<label class="f" for="email">Email (your certificate goes here)</label>
|
|
183
178
|
<input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
|
|
184
|
-
<label class="f" for="coin">Pay with</label>
|
|
185
|
-
<select id="coin" name="coin" required></select>
|
|
186
179
|
<label class="agree"><input type="checkbox" name="agree" required> <span>I agree to the <a href="/license/terms" target="_blank" rel="noopener">licence terms</a>, including the credit with a link to the artist's page.</span></label>
|
|
187
180
|
<button id="pay" type="submit">Pay</button>
|
|
188
181
|
</form>
|
|
189
182
|
<p id="status" role="status" aria-live="polite"></p>
|
|
190
|
-
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/<code>.</small>
|
|
183
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/<code>.</small>
|
|
191
184
|
</div>`, { data: JSON.stringify({ kind: quote.kind, ...(quote.kind === "catalog" ? {} : { server: ctx.serverId(quote.server), artist: quote.artist, album: quote.album, n: quote.n }) }) }), method);
|
|
192
185
|
return true;
|
|
193
186
|
}
|
|
@@ -214,9 +207,9 @@ export async function handleLicenseRoutes(request: IncomingMessage, response: Se
|
|
|
214
207
|
}
|
|
215
208
|
const body = license.status === "paid"
|
|
216
209
|
? certificate(license, ctx, true)
|
|
217
|
-
: `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)}
|
|
210
|
+
: `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)}${escape(paidIn(license.coin))}</p>
|
|
218
211
|
<p>Waiting for your payment. Once CoinPay confirms it, your certificate is here and in your email (${escape(license.email)}). This page checks again when you reload it.</p>
|
|
219
|
-
<a class="go" href="
|
|
212
|
+
<a class="go" href="${escape(payUrl(license.paymentId))}">Open the payment</a></div>`;
|
|
220
213
|
html(response, 200, page(`Licence ${license.code}`, body), method);
|
|
221
214
|
return true;
|
|
222
215
|
}
|
package/src/music-license.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
19
19
|
import type { Db, Support } from "./support.ts";
|
|
20
|
-
import { SupportError } from "./support.ts";
|
|
20
|
+
import { SupportError, coinOf } from "./support.ts";
|
|
21
21
|
import type { MusicStore } from "./music-store.ts";
|
|
22
22
|
|
|
23
23
|
export type Tier = "creator" | "indie" | "commercial" | "catalog";
|
|
@@ -203,7 +203,8 @@ export class MusicLicenses {
|
|
|
203
203
|
const code = newCode();
|
|
204
204
|
const checkout = await this.options.support.pay({
|
|
205
205
|
amountUsd: quote.amountCents / 100,
|
|
206
|
-
|
|
206
|
+
email,
|
|
207
|
+
accountId: account?.id || email,
|
|
207
208
|
order: `license-${id}`,
|
|
208
209
|
description: `${TIER_NAMES[quote.tier]} licence: ${quote.title}${quote.kind === "catalog" ? ", one year" : ` by ${quote.artistName}`} for ${project}`,
|
|
209
210
|
metadata: { product: "music-license", license: id, tier: quote.tier, kind: quote.kind },
|
|
@@ -213,7 +214,7 @@ export class MusicLicenses {
|
|
|
213
214
|
title, artist_name, credit_url, amount_cents, coin, payment_id, terms, created_at)
|
|
214
215
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`,
|
|
215
216
|
[id, code, token, email, account?.id ?? "", licensee, project, httpsUrl(input["projectUrl"]), quote.tier, quote.kind, quote.server, quote.artist, quote.album, quote.n,
|
|
216
|
-
quote.title, quote.artistName, quote.creditUrl, quote.amountCents,
|
|
217
|
+
quote.title, quote.artistName, quote.creditUrl, quote.amountCents, coinOf(input["coin"]), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
|
|
217
218
|
return { url: checkout.url, id, token, code };
|
|
218
219
|
}
|
|
219
220
|
|
package/src/music-store.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
19
19
|
import type { Db, Support } from "./support.ts";
|
|
20
|
-
import { SupportError } from "./support.ts";
|
|
20
|
+
import { SupportError, coinOf } from "./support.ts";
|
|
21
21
|
|
|
22
22
|
export type Kind = "song" | "album" | "artist";
|
|
23
23
|
|
|
@@ -225,7 +225,8 @@ export class MusicStore {
|
|
|
225
225
|
const token = randomUUID().replace(/-/g, "");
|
|
226
226
|
const checkout = await this.options.support.pay({
|
|
227
227
|
amountUsd: item.amountCents / 100,
|
|
228
|
-
|
|
228
|
+
email,
|
|
229
|
+
accountId: account?.id || email,
|
|
229
230
|
order: `music-${id}`,
|
|
230
231
|
description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
|
|
231
232
|
metadata: { product: "music", purchase: id, kind: item.kind },
|
|
@@ -233,7 +234,7 @@ export class MusicStore {
|
|
|
233
234
|
});
|
|
234
235
|
await this.options.db.query(`INSERT INTO music_purchases (id, token, email, user_id, server, kind, artist, album, n, title, artist_name, amount_cents, coin, payment_id, created_at)
|
|
235
236
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
|
|
236
|
-
[id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents,
|
|
237
|
+
[id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, coinOf(input["coin"]), checkout.paymentId, new Date(this.now())]);
|
|
237
238
|
return { url: checkout.url, id, token };
|
|
238
239
|
}
|
|
239
240
|
|
package/src/server.ts
CHANGED
|
@@ -2726,10 +2726,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2726
2726
|
}
|
|
2727
2727
|
let event: ReturnType<typeof readCoinpayWebhook>;
|
|
2728
2728
|
try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
|
|
2729
|
+
// A hosted checkout session's payment names its session; the purchase
|
|
2730
|
+
// row holds the session id (cs_...), an older one the payment id.
|
|
2731
|
+
const refs = [event.sessionId, event.paymentId].filter(Boolean);
|
|
2729
2732
|
if (options.musicStore) {
|
|
2730
2733
|
try {
|
|
2731
|
-
|
|
2732
|
-
|
|
2734
|
+
for (const ref of refs) {
|
|
2735
|
+
if (await options.musicStore.owns(ref)) { json(response, 200, await options.musicStore.settle(ref)); return; }
|
|
2736
|
+
if (options.musicLicenses && await options.musicLicenses.owns(ref)) { json(response, 200, await options.musicLicenses.settle(ref)); return; }
|
|
2737
|
+
}
|
|
2733
2738
|
} catch (error) {
|
|
2734
2739
|
console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
|
|
2735
2740
|
json(response, 500, { error: "could not settle; retry" });
|
|
@@ -2738,7 +2743,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2738
2743
|
}
|
|
2739
2744
|
if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
|
|
2740
2745
|
try {
|
|
2741
|
-
json(response, 200, await options.translationPasses.settle(event
|
|
2746
|
+
json(response, 200, await options.translationPasses.settle(event));
|
|
2742
2747
|
} catch (error) {
|
|
2743
2748
|
// A 5xx makes CoinPay deliver it again, which is what an unreachable
|
|
2744
2749
|
// CoinPay or database needs.
|
|
@@ -2884,7 +2889,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2884
2889
|
// llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
|
|
2885
2890
|
!(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
|
|
2886
2891
|
// Supporting a copyright holder is for anybody listening.
|
|
2887
|
-
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2892
|
+
!/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/support\/pay\/[0-9a-f-]{36}\/[0-9a-f]{32}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
|
|
2888
2893
|
// A station's short link, which sends the browser to wherever it plays.
|
|
2889
2894
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2890
2895
|
// A live room's page: the room's own key travels in ?play=, checked by its server.
|
|
@@ -2979,6 +2984,41 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2979
2984
|
return;
|
|
2980
2985
|
}
|
|
2981
2986
|
|
|
2987
|
+
/*
|
|
2988
|
+
* A monthly support's "pay this month" link, from its emails: a fresh
|
|
2989
|
+
* CoinPay checkout session each time it is followed (one still open is
|
|
2990
|
+
* reused), so a late payer is never sent to an expired session.
|
|
2991
|
+
*/
|
|
2992
|
+
const supportPay = /^\/support\/pay\/([0-9a-f-]{36})\/([0-9a-f]{32})$/.exec(path);
|
|
2993
|
+
if (supportPay && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
|
|
2994
|
+
const page = (status: number, words: string, again?: string): void => {
|
|
2995
|
+
const safe = (text: string): string => text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]!);
|
|
2996
|
+
response.writeHead(status, { "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", "x-robots-tag": "noindex" });
|
|
2997
|
+
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(words)}</p>${again ? `<p><a style="color:#3ddc84" href="${safe(again)}">${safe(again.includes("/support/pay/") ? "Try again" : "Start again")}</a></p>` : ""}<p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
|
|
2998
|
+
};
|
|
2999
|
+
// A HEAD (a mail scanner checking the link) never opens a checkout.
|
|
3000
|
+
if (request.method === "HEAD") { page(200, ""); return; }
|
|
3001
|
+
const verdict = guard.check(`support-pay:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
|
|
3002
|
+
if (!verdict.ok) { page(429, "Too many at once; wait a minute and open the link again."); return; }
|
|
3003
|
+
try {
|
|
3004
|
+
const opened = await options.supportSubscriptions.open(supportPay[1]!, supportPay[2]!);
|
|
3005
|
+
if (opened.state === "redirect") {
|
|
3006
|
+
response.writeHead(303, { location: opened.url, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
3007
|
+
response.end();
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
3010
|
+
const name = options.support?.holder(opened.holder)?.name ?? opened.holder;
|
|
3011
|
+
const site = options.site ?? "https://nixamp.com";
|
|
3012
|
+
if (opened.state === "paid") page(200, `This month's support for ${name} is paid. Thank you. Next month's link comes by email.`);
|
|
3013
|
+
else if (opened.state === "canceled") page(200, `Your monthly support for ${name} was cancelled, so there is nothing to pay.`, `${site}/support/${opened.holder}`);
|
|
3014
|
+
else page(200, `Your monthly support for ${name} stopped after a month went unpaid. Nothing is owed.`, `${site}/support/${opened.holder}`);
|
|
3015
|
+
} catch (error) {
|
|
3016
|
+
if (error instanceof SupportError) page(error.status, error.message, error.status >= 500 ? `${url.pathname}` : undefined);
|
|
3017
|
+
else page(502, "The payment service did not answer; nothing was charged. Try the link again in a minute.", url.pathname);
|
|
3018
|
+
}
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
2982
3022
|
/* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
|
|
2983
3023
|
const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
|
|
2984
3024
|
if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
|
|
@@ -3012,11 +3052,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3012
3052
|
}
|
|
3013
3053
|
let body: Record<string, unknown> = {};
|
|
3014
3054
|
try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
|
|
3055
|
+
// CoinPay files the payment under the payer: a signed-in member by
|
|
3056
|
+
// account id (their email if the form left it empty), a guest by email.
|
|
3057
|
+
const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
|
|
3058
|
+
const payer = {
|
|
3059
|
+
email: typeof body["email"] === "string" && body["email"].trim() ? body["email"] : me?.email ?? "",
|
|
3060
|
+
accountId: me?.id ?? null,
|
|
3061
|
+
};
|
|
3015
3062
|
if (body["monthly"] === true) {
|
|
3016
3063
|
if (!options.supportSubscriptions) throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
|
|
3017
|
-
json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
|
|
3064
|
+
json(response, 200, await options.supportSubscriptions.start({ ...body, ...payer, holder: holder.id }));
|
|
3018
3065
|
} else {
|
|
3019
|
-
json(response, 200, await support.checkout({ ...body, holder: holder.id }));
|
|
3066
|
+
json(response, 200, await support.checkout({ ...body, ...payer, holder: holder.id }));
|
|
3020
3067
|
}
|
|
3021
3068
|
return;
|
|
3022
3069
|
}
|
|
@@ -4107,8 +4154,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4107
4154
|
if (!guard.check(`translation-checkout-ip:${caller}`, { allowed: 10, windowMs: 3600_000 }).ok) throw new SpeechError("Too many new checkouts. Resume your pending purchase.", 429);
|
|
4108
4155
|
let body: Record<string, unknown>;
|
|
4109
4156
|
try { body = JSON.parse(await readBody(request, 1024)); } catch { throw new SpeechError("Choose a translation pass.", 400); }
|
|
4110
|
-
if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["
|
|
4111
|
-
|
|
4157
|
+
if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["requestKey"] !== "string") throw new SpeechError("Choose a translation pass.", 400);
|
|
4158
|
+
// A CoinPay hosted session (any coin or PayPal) filed under this
|
|
4159
|
+
// account and its email; `coin` only comes from an older open page.
|
|
4160
|
+
const email = who.email || (typeof body["email"] === "string" ? body["email"] : "");
|
|
4161
|
+
json(response, 200, await service.checkout(who.id, body["plan"], typeof body["coin"] === "string" ? body["coin"] : "", body["requestKey"], { email, accountId: who.id })); return;
|
|
4112
4162
|
}
|
|
4113
4163
|
const order = path.match(/^\/api\/v1\/translation-passes\/orders\/([^/]+)$/);
|
|
4114
4164
|
if (order && request.method === "GET") {
|
|
@@ -8019,7 +8069,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
8019
8069
|
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
|
|
8020
8070
|
// Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
|
|
8021
8071
|
const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
|
|
8022
|
-
// Monthly support: each month
|
|
8072
|
+
// Monthly support: each month its stable pay link by email (a fresh CoinPay session per click).
|
|
8023
8073
|
const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
|
|
8024
8074
|
db: pool as unknown as import("./support.ts").Db,
|
|
8025
8075
|
support: supportPayments,
|
package/src/store-routes.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { createHash } from "node:crypto";
|
|
|
13
13
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
14
14
|
import { PRICES, type MusicStore } from "./music-store.ts";
|
|
15
15
|
import { serverId } from "./music-hub.ts";
|
|
16
|
-
import { SupportError } from "./support.ts";
|
|
16
|
+
import { SupportError, paidIn, payUrl } from "./support.ts";
|
|
17
17
|
|
|
18
18
|
const PURCHASE = /^\/purchases\/([0-9a-f-]{36})$/;
|
|
19
19
|
const ONE = /^\/api\/v1\/store\/purchases\/([0-9a-f-]{36})$/;
|
|
@@ -61,12 +61,6 @@ const BUY_SCRIPT = `
|
|
|
61
61
|
(async () => {
|
|
62
62
|
const $ = (s) => document.querySelector(s);
|
|
63
63
|
const status = $("#status");
|
|
64
|
-
try {
|
|
65
|
-
const coins = await (await fetch("/api/v1/store/coins")).json();
|
|
66
|
-
const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
|
|
67
|
-
const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
|
|
68
|
-
$("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
|
|
69
|
-
} catch { status.textContent = "Payments are not available right now."; }
|
|
70
64
|
$("#buy").addEventListener("submit", async (event) => {
|
|
71
65
|
event.preventDefault();
|
|
72
66
|
const button = $("#pay");
|
|
@@ -75,7 +69,7 @@ const BUY_SCRIPT = `
|
|
|
75
69
|
const form = new FormData(event.currentTarget);
|
|
76
70
|
try {
|
|
77
71
|
const r = await fetch("/api/v1/store/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
|
|
78
|
-
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email")
|
|
72
|
+
body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email") }) });
|
|
79
73
|
const body = await r.json();
|
|
80
74
|
if (!r.ok) throw new Error(body.error || "The checkout did not start.");
|
|
81
75
|
status.textContent = "Taking you to CoinPay…";
|
|
@@ -128,12 +122,10 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
|
|
|
128
122
|
<form id="buy">
|
|
129
123
|
<label for="email">Email (your download link goes here)</label>
|
|
130
124
|
<input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
|
|
131
|
-
<
|
|
132
|
-
<select id="coin" name="coin" required></select>
|
|
133
|
-
<button id="pay" type="submit">Pay ${price(item.amountCents)} with crypto</button>
|
|
125
|
+
<button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
|
|
134
126
|
</form>
|
|
135
127
|
<p id="status" role="status" aria-live="polite"></p>
|
|
136
|
-
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. For small amounts
|
|
128
|
+
<small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. For small amounts a coin with low fees (USDC on Polygon or Solana, SOL) or PayPal costs least. Listening on the live stations is always free.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
|
|
137
129
|
</div>`, JSON.stringify({ server: serverId(item.server), kind: item.kind, artist: item.artist, album: item.album, n: item.n })), method);
|
|
138
130
|
return true;
|
|
139
131
|
}
|
|
@@ -148,9 +140,9 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
|
|
|
148
140
|
? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
|
|
149
141
|
<a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
|
|
150
142
|
<small>MP3s with the cover art and lyrics, plus each song's .lyrics.txt, cover.jpg and album.json. Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under <a href="/music/me">My music</a>, to play or download.</small></div>`
|
|
151
|
-
: `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}
|
|
143
|
+
: `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
|
|
152
144
|
<p>Waiting for your payment. Once CoinPay confirms it, your download is here and in your email (${escape(purchase.email)}). This page checks again when you reload it.</p>
|
|
153
|
-
<a class="go" href="
|
|
145
|
+
<a class="go" href="${escape(payUrl(purchase.paymentId))}">Open the payment</a></div>`;
|
|
154
146
|
html(response, 200, page(purchase.title, body), method);
|
|
155
147
|
return true;
|
|
156
148
|
}
|