nixamp 0.61.1 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-label.d.ts +2 -1
- package/dist/ai-label.js +5 -3
- package/dist/label-routes.js +3 -3
- package/dist/license-routes.d.ts +17 -0
- package/dist/license-routes.js +241 -0
- package/dist/music-hub.d.ts +1 -0
- package/dist/music-hub.js +9 -6
- package/dist/music-license.d.ts +120 -0
- package/dist/music-license.js +266 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +30 -0
- package/package.json +1 -1
- package/src/ai-label.ts +5 -4
- package/src/label-routes.ts +3 -3
- package/src/license-routes.ts +258 -0
- package/src/music-hub.ts +10 -7
- package/src/music-license.ts +349 -0
- package/src/server.ts +29 -0
- package/web/dist/assets/{ar-CYzjQKmG.js → ar-Eigh2EY7.js} +1 -1
- package/web/dist/assets/client-B_mfdrFh.js +1 -0
- package/web/dist/assets/{cs-DiSibpnb.js → cs-BiYj6suH.js} +1 -1
- package/web/dist/assets/{da-butHBZ3x.js → da-Bbv8v9LC.js} +1 -1
- package/web/dist/assets/{de-CmBXpW1X.js → de-Ct4J4O2-.js} +1 -1
- package/web/dist/assets/{es-CpqprbhX.js → es-C1QHkBcK.js} +1 -1
- package/web/dist/assets/{fi-B1hMfasn.js → fi-BL22uj7L.js} +1 -1
- package/web/dist/assets/{fr-Cjc6bkRE.js → fr-CbHGddiD.js} +1 -1
- package/web/dist/assets/{hi-DjEO-zwR.js → hi-CEnEoHJB.js} +1 -1
- package/web/dist/assets/{hu-BsR0uaL9.js → hu-V6N4YyNP.js} +1 -1
- package/web/dist/assets/{id-B9R43LCb.js → id-Dlur-A5Q.js} +1 -1
- package/web/dist/assets/{it-qzlsZzmL.js → it-CtiClrIo.js} +1 -1
- package/web/dist/assets/main-CFJDb9Yd.js +22 -0
- package/web/dist/assets/{music-CVLJhYTF.css → music-Dy7J3PeE.css} +1 -1
- package/web/dist/assets/music-gaYfGADg.js +1 -0
- package/web/dist/assets/{nl-CZUfUvgR.js → nl-Crjz8b-j.js} +1 -1
- package/web/dist/assets/{ru-BBAO-YaP.js → ru-CrwKGInd.js} +1 -1
- package/web/dist/assets/{sv-ySaoJU94.js → sv-DG2OJzJH.js} +1 -1
- package/web/dist/assets/{uk-FeqZ0VuT.js → uk-BteiNgtQ.js} +1 -1
- package/web/dist/assets/{vi-BKb_A-rW.js → vi-CSTApJSE.js} +1 -1
- package/web/dist/assets/{zh-DfWRilkD.js → zh-CTag10X-.js} +1 -1
- package/web/dist/index.html +2 -1
- package/web/dist/music.html +3 -2
- package/web/dist/sw.js +22 -21
- package/web/dist/assets/main-DACzAtIi.js +0 -22
- package/web/dist/assets/music-BTeZvJCU.js +0 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Music licences on nixamp.com (music-license.ts): the pages and the API.
|
|
3
|
+
*
|
|
4
|
+
* GET /license?server=&kind=song|album&artist=&album=&n= pick a licence for a song or album, then pay
|
|
5
|
+
* GET /license?kind=catalog the Catalog pass, $29 a year
|
|
6
|
+
* GET /license/terms the terms, in plain words
|
|
7
|
+
* GET /license/NXL-XXXX-XXXX anybody's check of a licence
|
|
8
|
+
* GET /licenses/:id?t= the licensee's certificate (printable)
|
|
9
|
+
* POST /api/v1/licenses/checkout {tier, kind, server?, artist?, album?, n?, licensee, project, projectUrl?, email, coin, agree} -> {url}
|
|
10
|
+
* GET /api/v1/licenses/NXL-XXXX-XXXX the check, as JSON
|
|
11
|
+
* GET /api/v1/licenses the signed-in account's licences
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
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";
|
|
17
|
+
|
|
18
|
+
const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
|
|
19
|
+
const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
|
|
20
|
+
const CERTIFICATE = /^\/licenses\/([0-9a-f-]{36})$/;
|
|
21
|
+
|
|
22
|
+
export function isLicensePath(path: string): boolean {
|
|
23
|
+
return path === "/license" || path === "/license/terms" || CHECK.test(path) || CERTIFICATE.test(path)
|
|
24
|
+
|| path === "/api/v1/licenses/checkout" || path === "/api/v1/licenses" || CHECK_JSON.test(path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LicenseRoutesContext {
|
|
28
|
+
licenses: MusicLicenses;
|
|
29
|
+
account: () => Promise<{ id: string; email: string } | null>;
|
|
30
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
31
|
+
readBody: (request: IncomingMessage) => Promise<string>;
|
|
32
|
+
/** The opaque id a server goes by in nixamp.com's addresses. */
|
|
33
|
+
serverId: (base: string) => string;
|
|
34
|
+
site: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const escape = (text: string): string => text.replace(/[&<>"']/g, (one) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[one]!);
|
|
38
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
39
|
+
const price = (cents: number): string => `$${(cents / 100).toFixed(cents % 100 ? 2 : 0)}`;
|
|
40
|
+
const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
|
|
41
|
+
|
|
42
|
+
const STYLE = `
|
|
43
|
+
:root{--bg:#080c09;--fg:#e8efe9;--muted:#9aa79d;--card:#0e1410;--line:#1f2a22;--accent:#3ddc84}
|
|
44
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
45
|
+
main{max-width:640px;margin:0 auto;padding:40px 16px}
|
|
46
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px;margin-bottom:16px}
|
|
47
|
+
h1{margin:0 0 4px;font-size:1.5rem;overflow-wrap:anywhere}h2{font-size:1.05rem;margin:22px 0 6px}.by{color:var(--muted);margin:0 0 16px}
|
|
48
|
+
.cover{border-radius:10px;float:right;margin:0 0 8px 12px;width:120px;height:120px;object-fit:cover;max-width:35%}
|
|
49
|
+
fieldset{border:0;padding:0;margin:16px 0 0}legend{color:var(--muted);font-size:.9rem;margin-bottom:6px}
|
|
50
|
+
.tier{display:grid;grid-template-columns:auto 1fr auto;gap:4px 12px;align-items:start;border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:8px;cursor:pointer}
|
|
51
|
+
.tier:has(input:checked){border-color:var(--accent)}.tier strong{display:block}.tier span{color:var(--muted);font-size:.9rem}.tier b{color:var(--accent);white-space:nowrap}
|
|
52
|
+
label.f{display:block;color:var(--muted);font-size:.9rem;margin:12px 0 4px}
|
|
53
|
+
input[type=text],input[type=email],input[type=url],select{width:100%;padding:10px;border-radius:10px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:inherit}
|
|
54
|
+
.agree{display:flex;gap:8px;align-items:flex-start;margin-top:14px;color:var(--muted);font-size:.9rem}
|
|
55
|
+
button,a.go{display:block;text-align:center;text-decoration:none;margin-top:18px;width:100%;padding:12px;border:0;border-radius:999px;background:var(--accent);color:#06120a;font:inherit;font-weight:800;cursor:pointer}
|
|
56
|
+
button:disabled{opacity:.5;cursor:wait}
|
|
57
|
+
:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
|
58
|
+
#status{min-height:1.4em;margin-top:12px;color:var(--muted)}
|
|
59
|
+
small,.muted{color:var(--muted)}a{color:var(--accent)}
|
|
60
|
+
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:16px 0}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}
|
|
61
|
+
.ok{display:inline-block;padding:2px 10px;border-radius:999px;background:#14361f;color:var(--accent);font-weight:700;font-size:.85rem}
|
|
62
|
+
.no{display:inline-block;padding:2px 10px;border-radius:999px;background:#3a1717;color:#ff8f8f;font-weight:700;font-size:.85rem}
|
|
63
|
+
code{display:block;white-space:pre-wrap;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px;font-size:.9rem;overflow-wrap:anywhere}
|
|
64
|
+
.print{background:transparent;border:1px solid var(--line);color:var(--fg)}
|
|
65
|
+
@media print{:root{--bg:#fff;--fg:#000;--muted:#444;--card:#fff;--line:#999;--accent:#000}.noprint{display:none}.card{border-color:#000}}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const SCRIPT = `
|
|
69
|
+
(() => {
|
|
70
|
+
const $ = (s) => document.querySelector(s);
|
|
71
|
+
const print = $("#print");
|
|
72
|
+
if (print) print.addEventListener("click", () => window.print());
|
|
73
|
+
const form = $("#license");
|
|
74
|
+
if (!form) return;
|
|
75
|
+
const status = $("#status");
|
|
76
|
+
const pay = $("#pay");
|
|
77
|
+
const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price + " with crypto" : "Pick a licence"; };
|
|
78
|
+
form.addEventListener("change", label);
|
|
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
|
+
form.addEventListener("submit", async (event) => {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
pay.disabled = true;
|
|
88
|
+
status.textContent = "Starting the checkout…";
|
|
89
|
+
const data = new FormData(form);
|
|
90
|
+
try {
|
|
91
|
+
const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
|
|
92
|
+
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"), coin: data.get("coin"), agree: data.get("agree") === "on" }) });
|
|
94
|
+
const body = await r.json();
|
|
95
|
+
if (!r.ok) throw new Error(body.error || "The checkout did not start.");
|
|
96
|
+
status.textContent = "Taking you to CoinPay…";
|
|
97
|
+
location.href = body.url;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
status.textContent = error.message;
|
|
100
|
+
pay.disabled = false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
})();
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
export const LICENSE_CSP = ["default-src 'none'", `script-src ${hash(SCRIPT)}`, `style-src ${hash(STYLE)}`, "img-src 'self' https:", "connect-src 'self'", "form-action 'none'", "base-uri 'none'", "frame-ancestors 'none'"].join("; ");
|
|
107
|
+
|
|
108
|
+
function page(title: string, body: string, options: { data?: string; index?: boolean; description?: string } = {}): string {
|
|
109
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
110
|
+
<title>${escape(title)} · nixamp</title>${options.index ? "" : `<meta name="robots" content="noindex">`}${options.description ? `<meta name="description" content="${escape(options.description)}">` : ""}
|
|
111
|
+
<link rel="icon" href="/favicon.png" type="image/png"><style>${STYLE}</style></head>
|
|
112
|
+
<body${options.data ? ` data-item="${escape(options.data)}"` : ""}><main>${body}</main><script>${SCRIPT}</script></body></html>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function html(response: ServerResponse, status: number, text: string, method: string): void {
|
|
116
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8", "content-security-policy": LICENSE_CSP, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
117
|
+
response.end(method === "HEAD" ? undefined : text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The tiers sold for what is being licensed, cheapest first. */
|
|
121
|
+
function tiersFor(kind: Quote["kind"]): Tier[] {
|
|
122
|
+
return (Object.keys(LICENSE_PRICES) as Tier[]).filter((tier) => LICENSE_PRICES[tier][kind]).sort((a, b) => LICENSE_PRICES[a][kind]! - LICENSE_PRICES[b][kind]!);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function certificate(license: License, ctx: LicenseRoutesContext, full: boolean): string {
|
|
126
|
+
const view = ctx.licenses.view(license, full);
|
|
127
|
+
const active = Boolean(view["active"]);
|
|
128
|
+
return `<div class="card">
|
|
129
|
+
<p>${active ? `<span class="ok">Active licence</span>` : license.status === "paid" ? `<span class="no">Pass ended ${day(license.expiresAt)}</span>` : `<span class="no">Not paid yet</span>`}</p>
|
|
130
|
+
<h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1>
|
|
131
|
+
<p class="by">${escape(license.title)}${license.kind === "catalog" ? "" : ` · ${escape(license.artistName)}`}</p>
|
|
132
|
+
<dl>
|
|
133
|
+
<dt>Licensee</dt><dd>${escape(license.licensee)}</dd>
|
|
134
|
+
<dt>Project</dt><dd>${escape(license.project)}${license.projectUrl ? ` · <a href="${escape(license.projectUrl)}" rel="nofollow noopener">${escape(license.projectUrl)}</a>` : ""}</dd>
|
|
135
|
+
<dt>Covers</dt><dd>${escape(TIER_USE[license.tier])}</dd>
|
|
136
|
+
<dt>Licensor</dt><dd>${escape(ctx.licenses.holder)}</dd>
|
|
137
|
+
${license.paidAt ? `<dt>Issued</dt><dd>${day(license.paidAt)}</dd>` : ""}
|
|
138
|
+
${license.expiresAt ? `<dt>New projects until</dt><dd>${day(license.expiresAt)} (projects published before then stay licensed)</dd>` : `<dt>Term</dt><dd>For good, worldwide, non-exclusive</dd>`}
|
|
139
|
+
<dt>Terms</dt><dd><a href="/license/terms">Version ${escape(license.terms)}</a></dd>
|
|
140
|
+
<dt>Check</dt><dd><a href="/license/${escape(license.code)}">${escape(ctx.site)}/license/${escape(license.code)}</a></dd>
|
|
141
|
+
</dl>
|
|
142
|
+
<h2>Credit line</h2>
|
|
143
|
+
<p class="muted">Required wherever the project shows credits or a description, with the link:</p>
|
|
144
|
+
<code>${escape(String(view["credit"]))}</code>
|
|
145
|
+
${full ? `<button id="print" class="print noprint" type="button">Print or save as PDF</button>` : ""}
|
|
146
|
+
</div>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function handleLicenseRoutes(request: IncomingMessage, response: ServerResponse, path: string, url: URL, ctx: LicenseRoutesContext): Promise<boolean> {
|
|
150
|
+
if (!isLicensePath(path)) return false;
|
|
151
|
+
const method = request.method ?? "GET";
|
|
152
|
+
const licenses = ctx.licenses;
|
|
153
|
+
try {
|
|
154
|
+
if (path === "/license" && (method === "GET" || method === "HEAD")) {
|
|
155
|
+
const asked = Object.fromEntries(url.searchParams.entries());
|
|
156
|
+
const kind = asked["kind"] === "catalog" ? "catalog" : asked["kind"] === "album" ? "album" : "song";
|
|
157
|
+
let quote: Quote;
|
|
158
|
+
try {
|
|
159
|
+
quote = await licenses.quote({ ...asked, kind, tier: tiersFor(kind)[0] });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
html(response, 404, page("Not licensable", `<div class="card"><h1>Not licensable</h1><p class="by">${escape((error as Error).message)}</p><p><a href="/music">Back to the music</a></p></div>`), method);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const me = await ctx.account().catch(() => null);
|
|
165
|
+
const cover = quote.kind === "catalog" ? "" : `<img class="cover" src="${escape(`/api/v1/music/file/${ctx.serverId(quote.server)}/${quote.artist}/${quote.album}/cover.jpg`)}" alt="">`;
|
|
166
|
+
const tiers = tiersFor(quote.kind);
|
|
167
|
+
const options = tiers.map((tier, at) => `<label class="tier"><input type="radio" name="tier" value="${tier}" data-price="${price(LICENSE_PRICES[tier][quote.kind]!)}"${at === (tiers.length > 1 ? 1 : 0) ? " checked" : ""}>
|
|
168
|
+
<div><strong>${escape(TIER_NAMES[tier])}</strong><span>${escape(TIER_USE[tier])}</span></div><b>${price(LICENSE_PRICES[tier][quote.kind]!)}${tier === "catalog" ? "/year" : ""}</b></label>`).join("");
|
|
169
|
+
const what = quote.kind === "catalog" ? `Every song by every artist ${escape(licenses.holder)} releases on nixamp` : quote.kind === "album" ? `The album "${escape(quote.title)}" by ${escape(quote.artistName)}` : `"${escape(quote.title)}" by ${escape(quote.artistName)}`;
|
|
170
|
+
html(response, 200, page(`License ${quote.title}`, `<div class="card">${cover}
|
|
171
|
+
<h1>License ${quote.kind === "catalog" ? "the whole catalogue" : "this music"}</h1>
|
|
172
|
+
<p class="by">${what}</p>
|
|
173
|
+
<p class="muted">For your game, app, video or podcast. A download is for listening; a licence lets you put the music in your project and publish it. Worldwide, non-exclusive, and every use credits the artist with a link to their page.</p>
|
|
174
|
+
<form id="license">
|
|
175
|
+
<fieldset><legend>Licence</legend>${options}</fieldset>
|
|
176
|
+
<label class="f" for="licensee">Licensed to (your name or company)</label>
|
|
177
|
+
<input id="licensee" name="licensee" type="text" required minlength="2" maxlength="120" autocomplete="organization">
|
|
178
|
+
<label class="f" for="project">${quote.kind === "catalog" ? "Your studio, channel or business" : "Project the music goes in"}</label>
|
|
179
|
+
<input id="project" name="project" type="text" required minlength="2" maxlength="160">
|
|
180
|
+
<label class="f" for="projectUrl">Project link (optional)</label>
|
|
181
|
+
<input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
|
|
182
|
+
<label class="f" for="email">Email (your certificate goes here)</label>
|
|
183
|
+
<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
|
+
<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
|
+
<button id="pay" type="submit">Pay</button>
|
|
188
|
+
</form>
|
|
189
|
+
<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>
|
|
191
|
+
</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
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (path === "/license/terms" && (method === "GET" || method === "HEAD")) {
|
|
195
|
+
const sections = licenseTerms(licenses.holder, ctx.site).map((one) => `<h2>${escape(one.heading)}</h2><p>${escape(one.text)}</p>`).join("");
|
|
196
|
+
html(response, 200, page("Music licence terms", `<div class="card"><h1>Music licence terms</h1><p class="by">Version ${TERMS_VERSION} · ${escape(licenses.holder)}</p>${sections}<p><a href="/music">The music</a> · <a href="/license?kind=catalog">Catalog pass</a></p></div>`,
|
|
197
|
+
{ index: true, description: `How you may use ${licenses.holder}'s AI music in your games, apps and videos: Creator, Indie, Commercial and the Catalog pass.` }), method);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const check = CHECK.exec(path);
|
|
201
|
+
if (check && (method === "GET" || method === "HEAD")) {
|
|
202
|
+
const license = await licenses.byCode(check[1]!);
|
|
203
|
+
html(response, license ? 200 : 404, page(license ? `Licence ${license.code}` : "No such licence", license
|
|
204
|
+
? certificate(license, ctx, false)
|
|
205
|
+
: `<div class="card"><h1>No such licence</h1><p class="by">${escape(check[1]!)} is not a paid nixamp music licence.</p></div>`), method);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
const mine = CERTIFICATE.exec(path);
|
|
209
|
+
if (mine && (method === "GET" || method === "HEAD")) {
|
|
210
|
+
const license = await licenses.get(mine[1]!, url.searchParams.get("t") ?? "");
|
|
211
|
+
if (!license) {
|
|
212
|
+
html(response, 404, page("No such licence", `<div class="card"><h1>No such licence</h1><p class="by">That link is not one of ours.</p></div>`), method);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
const body = license.status === "paid"
|
|
216
|
+
? 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)} in ${escape(license.coin)}</p>
|
|
218
|
+
<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="https://coinpayportal.com/pay/${escape(license.paymentId)}">Open the payment</a></div>`;
|
|
220
|
+
html(response, 200, page(`Licence ${license.code}`, body), method);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
const checkJson = CHECK_JSON.exec(path);
|
|
224
|
+
if (checkJson && method === "GET") {
|
|
225
|
+
const license = await licenses.byCode(checkJson[1]!);
|
|
226
|
+
response.setHeader("access-control-allow-origin", "*");
|
|
227
|
+
ctx.json(response, license ? 200 : 404, license ? { license: licenses.view(license, false) } : { error: "no such licence", code: "not_found" });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (path === "/api/v1/licenses/checkout" && method === "POST") {
|
|
231
|
+
let input: Record<string, unknown>;
|
|
232
|
+
try {
|
|
233
|
+
input = JSON.parse(await ctx.readBody(request)) as Record<string, unknown>;
|
|
234
|
+
} catch {
|
|
235
|
+
ctx.json(response, 400, { error: "the body is not JSON", code: "bad_json" });
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
const me = await ctx.account().catch(() => null);
|
|
239
|
+
ctx.json(response, 201, await licenses.checkout(input, me));
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (path === "/api/v1/licenses" && method === "GET") {
|
|
243
|
+
const me = await ctx.account().catch(() => null);
|
|
244
|
+
if (!me) {
|
|
245
|
+
ctx.json(response, 401, { error: "sign in to see your licences", code: "unauthorized" });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
ctx.json(response, 200, { licenses: (await licenses.forAccount(me)).map((one) => licenses.view(one, true)) });
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
ctx.json(response, 405, { error: "not like that", code: "method_not_allowed" });
|
|
252
|
+
return true;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (error instanceof SupportError) ctx.json(response, error.status, { error: error.message, code: error.code });
|
|
255
|
+
else ctx.json(response, 500, { error: error instanceof Error ? error.message : String(error), code: "internal" });
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/music-hub.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* hands covers, portraits and words through itself.
|
|
9
9
|
*
|
|
10
10
|
* GET /api/v1/music the merged catalogue
|
|
11
|
-
* GET /api/v1/music/file/<server>/<path> a cover, portrait, profile or
|
|
11
|
+
* GET /api/v1/music/file/<server>/<path> a cover, portrait, profile, words or music video, through nixamp.com
|
|
12
12
|
* POST /api/v1/music/released a server says an album or song landed: its followers hear
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
@@ -27,7 +27,7 @@ export function artistFollowId(name: string): string {
|
|
|
27
27
|
return `artist:${slug}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
interface LabelArtist { key: string; name: string; genre: string; bio: string; members?: Array<{ slug: string; name: string; role: string; bio: string }>; albums: Array<{ key: string; title: string; year: number; concept: string; cover: boolean; complete: boolean; songs: Array<{ n: number; title: string; seconds: number; lyrics?: string }> }> }
|
|
30
|
+
interface LabelArtist { key: string; name: string; genre: string; bio: string; members?: Array<{ slug: string; name: string; role: string; bio: string }>; albums: Array<{ key: string; title: string; year: number; concept: string; cover: boolean; complete: boolean; songs: Array<{ n: number; title: string; seconds: number; lyrics?: string; video?: boolean }> }> }
|
|
31
31
|
interface LabelView { holder: string; genres: Array<{ genre: string; station: string; artists: LabelArtist[] }> }
|
|
32
32
|
interface GuideStation { id: string; name: string; page: string; state: string; group?: string; nowPlaying: { title: string; artist: string } | null }
|
|
33
33
|
|
|
@@ -125,7 +125,7 @@ export class MusicHub {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
const FILE = /^\/api\/v1\/music\/file\/([a-f0-9]{10})\/([a-f0-9]{10}\/(?:[a-f0-9]{8}\/(?:\d{1,3}\.txt|cover\.jpg)|artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg)))$/;
|
|
128
|
+
const FILE = /^\/api\/v1\/music\/file\/([a-f0-9]{10})\/([a-f0-9]{10}\/(?:[a-f0-9]{8}\/(?:\d{1,3}\.(?:txt|mp4)|cover\.jpg)|artist\.md|members\/[a-z0-9-]{1,40}\.(?:md|jpg)))$/;
|
|
129
129
|
|
|
130
130
|
const slugOf = (text: string, max: number): string => text.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, max);
|
|
131
131
|
|
|
@@ -220,12 +220,15 @@ export async function handleMusicRoutes(request: IncomingMessage, response: Serv
|
|
|
220
220
|
const base = ctx.hub.resolve(file[1]!);
|
|
221
221
|
if (!base) { ctx.json(response, 404, { error: "no such server" }); return true; }
|
|
222
222
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// A video is asked for in byte ranges (that is how a player seeks); passed on both ways.
|
|
224
|
+
const range = typeof request.headers.range === "string" ? request.headers.range : "";
|
|
225
|
+
const upstream = await fetch(`${base}/api/v1/label/${file[2]}`, { signal: AbortSignal.timeout(15_000), ...(range ? { headers: { range } } : {}) });
|
|
226
|
+
if (!upstream.ok || !upstream.body) { ctx.json(response, upstream.status === 404 ? 404 : upstream.status === 416 ? 416 : 502, { error: "not there" }); return true; }
|
|
227
|
+
const pass = (name: string): Record<string, string> => (upstream.headers.get(name) ? { [name]: upstream.headers.get(name)! } : {});
|
|
228
|
+
response.writeHead(upstream.status === 206 ? 206 : 200, {
|
|
226
229
|
"content-type": upstream.headers.get("content-type") ?? "application/octet-stream",
|
|
227
230
|
"cache-control": "public, max-age=3600",
|
|
228
|
-
...(
|
|
231
|
+
...pass("content-length"), ...pass("content-range"), ...pass("accept-ranges"),
|
|
229
232
|
});
|
|
230
233
|
if (method === "HEAD") { response.end(); return true; }
|
|
231
234
|
const reader = upstream.body.getReader();
|