Setup commercial launch: licenze + backend Cloudflare Worker

Client (Python/JS):
- core/license.py: attivazione, validazione, deactivate (HTTP client + JWT decode)
- core/config.py: campi license_* + URL endpoint + costanti grace/revalidate
- api/bridge.py: get_init_data ritorna license status; activate_license/
  deactivate_license/revalidate_license/open_purchase_page
- check_update riscritto: punta a musictools.djluza.com/api/latest con
  Bearer token, URL di download firmato dal server solo se licensed
- webui: schermata di attivazione bloccante all'avvio; sezione Licenza
  in Impostazioni con verifica/disattiva

Backend (server/):
- Cloudflare Worker + D1 + R2 (vedi server/README.md)
- Endpoints: /api/license/{activate,validate,deactivate}, /api/latest,
  /api/webhook/lemonsqueezy, /api/health
- JWT HS256 con rotazione; max 3 attivazioni/licenza
- Generazione licenze via webhook Lemon Squeezy + email Resend
- Schema D1 in migrations/0001_init.sql

Memory: nuova musictools-commercial-launch.md, rimosso pending obsoleto

NON taggare finche backend non e' deployato (l'app e' bloccante)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
luzadevandClaude Opus 4.7 committed 2026-06-08 23:36:41 +02:00
1 parent 05a2a58ace
commit 5202b2c586
19 files changed
+1572 -31

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
.wrangler/
.dev.vars
*.log
dist/
+88
View File
@@ -0,0 +1,88 @@
# MusicTools License & Update API
Cloudflare Worker che gestisce attivazione licenze, validazione e distribuzione binari MusicTools.
## Stack
- **Cloudflare Workers** (compute serverless, free tier 100k req/giorno)
- **Cloudflare D1** (sqlite gestito, free tier 5GB)
- **Cloudflare R2** (storage zip binari, free tier 10GB)
- **Lemon Squeezy** (Merchant of Record per i pagamenti, gestisce IVA UE)
- **Resend** (invio email license-key, free tier 3k email/mese)
## Setup iniziale
Una volta sola, da terminale dentro `server/`:
```bash
npm install
npx wrangler login # autenticati a Cloudflare
# 1. Crea il database D1
npx wrangler d1 create musictools-licenses
# -> copia il database_id stampato nel wrangler.toml
# 2. Applica lo schema
npm run db:migrate:prod
# 3. Crea il bucket R2 per i binari
npx wrangler r2 bucket create musictools-builds
# 4. Imposta i secret (NON in chiaro nel wrangler.toml)
npx wrangler secret put JWT_SECRET # > openssl rand -base64 32
npx wrangler secret put LEMONSQUEEZY_SIGNING_SECRET # > dal dashboard LS
npx wrangler secret put RESEND_API_KEY # > dal dashboard Resend
# 5. Deploy
npm run deploy
```
## DNS
Su Cloudflare Dashboard > djluza.com > DNS aggiungi:
```
musictools CNAME <subdomain-worker>.workers.dev proxied
```
Poi vai su Workers & Pages > musictools-api > Settings > Triggers > Custom Domains
e aggiungi `musictools.djluza.com`.
## Endpoints
| Metodo | Path | Auth | Descrizione |
|---|---|---|---|
| POST | `/api/license/activate` | — | Attiva licenza, ritorna JWT |
| POST | `/api/license/validate` | — (token nel body) | Rivalida + ruota token |
| POST | `/api/license/deactivate` | — (token nel body) | Libera uno slot |
| GET | `/api/latest?platform=…` | Bearer token (opzionale) | Versione + URL download firmato |
| POST | `/api/webhook/lemonsqueezy` | X-Signature HMAC | Crea licenza dopo ordine |
| GET | `/api/health` | — | Healthcheck |
## Workflow pubblicazione release
1. GitHub Actions builda macOS e Windows zip (gia in place).
2. Step manuale (per ora): scarica i due zip, caricali su R2:
```bash
npx wrangler r2 object put musictools-builds/v1.5.3/MusicTools-macOS.zip --file=MusicTools-macOS.zip
npx wrangler r2 object put musictools-builds/v1.5.3/MusicTools-Windows.zip --file=MusicTools-Windows.zip
```
3. Inserisci il record `releases`:
```sql
INSERT INTO releases (version, platform, r2_key, size_bytes, sha256, notes, published_at)
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', strftime('%s','now'));
```
(eseguibile da `npx wrangler d1 execute musictools-licenses --remote --command "..."`)
In futuro: workflow GitHub Actions che fa upload R2 + insert D1 in automatico.
## TODO
- [ ] Implementare `/api/download` che verifica firma e fa stream da R2
- [ ] Endpoint admin per emettere licenze a mano (es. recensori, refund)
- [ ] Rate limiting con KV su `/api/license/activate` (anti brute-force)
- [ ] Cron worker giornaliero che marca le licenze inattive da > 1 anno
## Costi
A 0 vendite: **0€/mese** (tutto in free tier).
A 100 vendite/mese: ~5€ Lemon Squeezy commission + 0€ Cloudflare = ~5€.
+42
View File
@@ -0,0 +1,42 @@
-- Schema iniziale licenze MusicTools
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_key TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | revoked | refunded
source TEXT, -- es. 'lemonsqueezy', 'manual'
order_id TEXT, -- id dell'ordine LS
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_licenses_email ON licenses(email);
CREATE TABLE IF NOT EXISTS activations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
device_name TEXT,
app_version TEXT,
activated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
revoked_at INTEGER,
UNIQUE (license_id, device_id)
);
CREATE INDEX IF NOT EXISTS idx_activations_license ON activations(license_id);
CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL,
platform TEXT NOT NULL, -- macos | windows
r2_key TEXT NOT NULL, -- chiave dentro il bucket R2
size_bytes INTEGER,
sha256 TEXT,
notes TEXT,
published_at INTEGER NOT NULL,
UNIQUE (version, platform)
);
CREATE INDEX IF NOT EXISTS idx_releases_platform_pub ON releases(platform, published_at DESC);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "musictools-license-server",
"version": "0.1.0",
"private": true,
"description": "License + update API for MusicTools (Cloudflare Workers + D1)",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:create": "wrangler d1 create musictools-licenses",
"db:migrate:local": "wrangler d1 migrations apply musictools-licenses --local",
"db:migrate:prod": "wrangler d1 migrations apply musictools-licenses --remote",
"tail": "wrangler tail"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251101.0",
"typescript": "^5.6.0",
"wrangler": "^3.95.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
export function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
...headers,
},
});
}
export function notFound(): Response {
return json({ error: "Not found" }, 404);
}
export function methodNotAllowed(): Response {
return json({ error: "Method not allowed" }, 405);
}
export function badRequest(msg: string): Response {
return json({ error: msg }, 400);
}
export function unauthorized(msg = "Unauthorized"): Response {
return json({ error: msg }, 401);
}
export async function readJson<T = any>(req: Request): Promise<T> {
try {
return (await req.json()) as T;
} catch {
throw new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
export function now(): number {
return Math.floor(Date.now() / 1000);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Minimal JWT HS256 implementation using Web Crypto (available in Workers).
* We don't pull in a library to keep the worker bundle tiny.
*/
function b64url(buf: ArrayBuffer | Uint8Array): string {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function b64urlDecode(s: string): Uint8Array {
s = s.replace(/-/g, "+").replace(/_/g, "/");
s += "=".repeat((4 - (s.length % 4)) % 4);
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmac(secret: string, data: string): Promise<ArrayBuffer> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
return crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
}
export interface JwtClaims {
sub: string; // license_id
key_id: string; // license_key (mascherata o intera)
email: string;
device_id: string;
iat: number;
exp: number;
[k: string]: unknown;
}
export async function signJwt(claims: JwtClaims, secret: string): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const head = b64url(new TextEncoder().encode(JSON.stringify(header)));
const body = b64url(new TextEncoder().encode(JSON.stringify(claims)));
const sig = b64url(await hmac(secret, `${head}.${body}`));
return `${head}.${body}.${sig}`;
}
export async function verifyJwt(token: string, secret: string): Promise<JwtClaims | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [head, body, sig] = parts;
const expected = b64url(await hmac(secret, `${head}.${body}`));
if (expected !== sig) return null;
try {
const claims = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as JwtClaims;
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
return null;
}
return claims;
} catch {
return null;
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Webhook Lemon Squeezy.
*
* Configurazione:
* - Crea il webhook dal dashboard LS (My Store > Settings > Webhooks)
* - URL: https://musictools.djluza.com/api/webhook/lemonsqueezy
* - Eventi: order_created, subscription_payment_success (per future estensioni),
* order_refunded
* - Secret: salvalo come "LEMONSQUEEZY_SIGNING_SECRET" (wrangler secret put)
*
* Flusso order_created:
* 1. Verifica firma X-Signature == HMAC-SHA256(secret, raw_body)
* 2. Estrai email cliente + order_id
* 3. Genera license_key (XXXX-XXXX-XXXX-XXXX), insert in 'licenses'
* 4. Invia email all'utente via Resend con la chiave
*/
import { json, now } from "./http";
import type { Env } from "./worker";
interface LSPayload {
meta?: { event_name?: string; custom_data?: Record<string, unknown> };
data?: {
id?: string;
type?: string;
attributes?: {
user_email?: string;
order_number?: number | string;
refunded?: boolean;
status?: string;
};
};
}
export async function handleLemonSqueezyWebhook(req: Request, env: Env): Promise<Response> {
const raw = await req.text();
const sig = req.headers.get("X-Signature") || "";
if (!sig || !env.LEMONSQUEEZY_SIGNING_SECRET) {
return json({ error: "Missing signature" }, 400);
}
const expected = await hmacHex(env.LEMONSQUEEZY_SIGNING_SECRET, raw);
if (!timingSafeEqual(sig, expected)) {
return json({ error: "Invalid signature" }, 401);
}
let payload: LSPayload;
try {
payload = JSON.parse(raw) as LSPayload;
} catch {
return json({ error: "Invalid JSON" }, 400);
}
const eventName = payload.meta?.event_name || "";
const attrs = payload.data?.attributes || {};
const email = (attrs.user_email || "").trim().toLowerCase();
const orderId = String(payload.data?.id || attrs.order_number || "");
if (!email || !orderId) {
return json({ error: "Missing email or order_id" }, 400);
}
const t = now();
if (eventName === "order_created") {
const key = generateLicenseKey();
try {
await env.DB.prepare(
`INSERT INTO licenses (license_key, email, status, source, order_id, created_at, updated_at)
VALUES (?1, ?2, 'active', 'lemonsqueezy', ?3, ?4, ?4)`
).bind(key, email, orderId, t).run();
} catch (e) {
// unique violation (webhook duplicato): no-op
console.warn("Insert license failed (probabile duplicato):", e);
return json({ ok: true, duplicate: true });
}
await sendLicenseEmail(env, email, key);
return json({ ok: true, license_key_masked: key.slice(0, 4) + "..." });
}
if (eventName === "order_refunded") {
await env.DB.prepare(
`UPDATE licenses SET status='refunded', updated_at=?1
WHERE order_id=?2`
).bind(t, orderId).run();
return json({ ok: true });
}
return json({ ok: true, ignored: eventName });
}
function generateLicenseKey(): string {
// 16 caratteri base32 (no I/O/0/1 ambigui), in 4 gruppi da 4.
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12), chars.slice(12, 16)]
.map((g) => g.join("")).join("-");
}
async function hmacHex(secret: string, data: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
async function sendLicenseEmail(env: Env, email: string, key: string): Promise<void> {
if (!env.RESEND_API_KEY) {
console.warn("RESEND_API_KEY non impostata, skip invio email");
return;
}
const body = {
from: "MusicTools <noreply@djluza.com>",
to: [email],
subject: "La tua licenza MusicTools",
html: `
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
<h1 style="color:#1db954">Grazie per aver scelto MusicTools!</h1>
<p>Ecco la tua chiave di licenza:</p>
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:14px;border-radius:8px;text-align:center">
${key}
</p>
<p>Per attivarla:</p>
<ol>
<li>Scarica MusicTools per <a href="https://musictools.djluza.com/download/macos">macOS</a> o <a href="https://musictools.djluza.com/download/windows">Windows</a></li>
<li>Apri l'app: ti chiedera' email e chiave</li>
<li>Inserisci questa email (<code>${email}</code>) e la chiave qui sopra</li>
</ol>
<p>Puoi attivare la licenza fino a 3 dispositivi.</p>
<hr/>
<p style="color:#666;font-size:12px">Hai problemi? Scrivici a info@djluza.com</p>
</div>
`,
};
const resp = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!resp.ok) {
console.error("Resend error:", await resp.text());
}
}
+179
View File
@@ -0,0 +1,179 @@
import { json, badRequest, unauthorized, readJson, now } from "./http";
import { signJwt, verifyJwt } from "./jwt";
import type { Env } from "./worker";
interface LicenseRow {
id: number;
license_key: string;
email: string;
status: string;
}
interface ActivationRow {
id: number;
license_id: number;
device_id: string;
device_name: string | null;
app_version: string | null;
activated_at: number;
last_seen_at: number;
revoked_at: number | null;
}
function normalizeEmail(s: string): string {
return (s || "").trim().toLowerCase();
}
function normalizeKey(s: string): string {
return (s || "").trim().toUpperCase();
}
async function getLicense(env: Env, key: string, email: string): Promise<LicenseRow | null> {
const stmt = env.DB.prepare(
`SELECT id, license_key, email, status FROM licenses
WHERE license_key = ?1 AND email = ?2 LIMIT 1`
).bind(key, email);
return await stmt.first<LicenseRow>();
}
async function countActiveActivations(env: Env, licenseId: number): Promise<number> {
const row = await env.DB.prepare(
`SELECT COUNT(*) AS n FROM activations
WHERE license_id = ?1 AND revoked_at IS NULL`
).bind(licenseId).first<{ n: number }>();
return row?.n ?? 0;
}
async function findActivation(env: Env, licenseId: number, deviceId: string): Promise<ActivationRow | null> {
return await env.DB.prepare(
`SELECT * FROM activations
WHERE license_id = ?1 AND device_id = ?2 LIMIT 1`
).bind(licenseId, deviceId).first<ActivationRow>();
}
async function issueToken(env: Env, license: LicenseRow, deviceId: string): Promise<string> {
const ttlDays = parseInt(env.TOKEN_TTL_DAYS || "30", 10);
const t = now();
return signJwt({
sub: String(license.id),
key_id: license.license_key,
email: license.email,
device_id: deviceId,
iat: t,
exp: t + ttlDays * 86400,
}, env.JWT_SECRET);
}
// ============================================================
// POST /api/license/activate
// ============================================================
export async function handleActivate(req: Request, env: Env): Promise<Response> {
const body = await readJson<{
key?: string; email?: string; device_id?: string;
device_name?: string; app_version?: string;
}>(req);
const key = normalizeKey(body.key || "");
const email = normalizeEmail(body.email || "");
const deviceId = (body.device_id || "").trim();
if (!key || !email || !deviceId) {
return badRequest("Missing key, email or device_id");
}
const license = await getLicense(env, key, email);
if (!license) {
return json({ error: "Chiave o email non corrispondono a un acquisto." }, 404);
}
if (license.status !== "active") {
return json({ error: "Licenza non piu' valida (rimborsata o revocata)." }, 403);
}
const max = parseInt(env.MAX_ACTIVATIONS || "3", 10);
const t = now();
const existing = await findActivation(env, license.id, deviceId);
if (existing) {
// Re-attivazione sullo stesso device: aggiorna last_seen.
await env.DB.prepare(
`UPDATE activations
SET app_version=?1, device_name=?2, last_seen_at=?3, revoked_at=NULL
WHERE id=?4`
).bind(body.app_version || null, body.device_name || null, t, existing.id).run();
} else {
const active = await countActiveActivations(env, license.id);
if (active >= max) {
return json({
error: `Hai gia attivato la licenza su ${max} dispositivi. Disattivane uno per usarla qui.`,
}, 409);
}
await env.DB.prepare(
`INSERT INTO activations
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
).bind(license.id, deviceId, body.device_name || null, body.app_version || null, t).run();
}
const token = await issueToken(env, license, deviceId);
return json({
token,
activated_at: t,
email: license.email,
});
}
// ============================================================
// POST /api/license/validate
// ============================================================
export async function handleValidate(req: Request, env: Env): Promise<Response> {
const body = await readJson<{ token?: string; device_id?: string; app_version?: string }>(req);
const token = (body.token || "").trim();
const deviceId = (body.device_id || "").trim();
if (!token || !deviceId) return badRequest("Missing token or device_id");
const claims = await verifyJwt(token, env.JWT_SECRET);
if (!claims) return unauthorized("Token invalido o scaduto");
if (claims.device_id !== deviceId) {
return unauthorized("device_id mismatch");
}
const license = await env.DB.prepare(
`SELECT id, license_key, email, status FROM licenses WHERE id = ?1`
).bind(claims.sub).first<LicenseRow>();
if (!license || license.status !== "active") {
return unauthorized("Licenza non attiva");
}
const act = await findActivation(env, license.id, deviceId);
if (!act || act.revoked_at !== null) {
return unauthorized("Attivazione non trovata o revocata");
}
await env.DB.prepare(
`UPDATE activations SET last_seen_at=?1, app_version=?2 WHERE id=?3`
).bind(now(), body.app_version || act.app_version, act.id).run();
// Rotazione token: ne emettiamo uno nuovo per estendere l'exp
const fresh = await issueToken(env, license, deviceId);
return json({ token: fresh, email: license.email });
}
// ============================================================
// POST /api/license/deactivate
// ============================================================
export async function handleDeactivate(req: Request, env: Env): Promise<Response> {
const body = await readJson<{ token?: string; device_id?: string }>(req);
const token = (body.token || "").trim();
const deviceId = (body.device_id || "").trim();
if (!token || !deviceId) return badRequest("Missing token or device_id");
const claims = await verifyJwt(token, env.JWT_SECRET);
if (!claims) return unauthorized("Token invalido");
if (claims.device_id !== deviceId) return unauthorized("device_id mismatch");
await env.DB.prepare(
`UPDATE activations SET revoked_at=?1
WHERE license_id=?2 AND device_id=?3 AND revoked_at IS NULL`
).bind(now(), claims.sub, deviceId).run();
return json({ ok: true });
}
+94
View File
@@ -0,0 +1,94 @@
import { json, badRequest, unauthorized } from "./http";
import { verifyJwt } from "./jwt";
import type { Env } from "./worker";
interface ReleaseRow {
version: string;
platform: string;
r2_key: string;
size_bytes: number | null;
sha256: string | null;
notes: string | null;
published_at: number;
}
/**
* GET /api/latest?platform=macos|windows&current=v1.5.2
* Authorization: Bearer <token> (opzionale ma necessario per ricevere download_url)
*
* Risposta:
* {
* version, notes, sha256,
* download_url (firmato, scade in DOWNLOAD_URL_TTL_SECONDS) -- solo se token valido
* }
*/
export async function handleLatest(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const platform = (url.searchParams.get("platform") || "").toLowerCase();
if (platform !== "macos" && platform !== "windows") {
return badRequest("platform must be macos or windows");
}
const row = await env.DB.prepare(
`SELECT version, platform, r2_key, size_bytes, sha256, notes, published_at
FROM releases
WHERE platform = ?1
ORDER BY published_at DESC
LIMIT 1`
).bind(platform).first<ReleaseRow>();
if (!row) {
return json({
version: env.LATEST_VERSION || "",
notes: "",
download_url: "",
requires_license: true,
});
}
// Auth opzionale: senza token rispondiamo solo con metadata (version + notes).
const auth = req.headers.get("Authorization") || "";
let licensed = false;
if (auth.startsWith("Bearer ")) {
const token = auth.slice(7).trim();
const claims = await verifyJwt(token, env.JWT_SECRET);
licensed = !!claims;
}
let downloadUrl = "";
if (licensed) {
// R2 non genera URL firmati nativi via Workers SDK in modo semplice.
// Soluzione: serviamo il file via questo Worker su un path firmato HMAC
// con scadenza. /api/download?key=<r2_key>&exp=<ts>&sig=<hmac>
downloadUrl = await signDownloadUrl(env, row.r2_key);
}
return json({
version: row.version,
notes: row.notes || "",
sha256: row.sha256 || "",
size_bytes: row.size_bytes || 0,
download_url: downloadUrl,
requires_license: !licensed,
});
}
async function signDownloadUrl(env: Env, r2Key: string): Promise<string> {
// Implementazione minima: torniamo un URL relativo che un altro endpoint
// /api/download verifichera prima di servire il file da R2.
// Per ora restituisco un placeholder; vai a implementare /api/download
// in un secondo passaggio se vuoi servire i binari dietro firma.
const ttl = parseInt(env.DOWNLOAD_URL_TTL_SECONDS || "300", 10);
const exp = Math.floor(Date.now() / 1000) + ttl;
const payload = `${r2Key}.${exp}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(env.JWT_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false, ["sign"],
);
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf)))
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
return `https://musictools.djluza.com/api/download?key=${encodeURIComponent(r2Key)}&exp=${exp}&sig=${sig}`;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* MusicTools License & Update API
*
* Endpoints:
* POST /api/license/activate body: { key, email, device_id, device_name, app_version }
* POST /api/license/validate body: { token, device_id, app_version }
* POST /api/license/deactivate body: { token, device_id }
* GET /api/latest?platform=macos|windows&current=v1.5.2 [Authorization: Bearer <token>]
* POST /api/webhook/lemonsqueezy (firmato HMAC, crea licenza dopo ordine)
*
* Auth model:
* - L'app non ha account: la "verita" e' (license_key, email).
* - Dopo activate(), il server emette un JWT HMAC con claims
* { sub: license_id, key_id, email, device_id, iat, exp }.
* Il client lo salva e lo manda a ogni revalidate / /api/latest.
* - revoke = update licenses.status='revoked' + tutti i validate falliscono.
*/
import { handleActivate, handleValidate, handleDeactivate } from "./license";
import { handleLatest } from "./updates";
import { handleLemonSqueezyWebhook } from "./lemonsqueezy";
import { json, methodNotAllowed, notFound } from "./http";
export interface Env {
DB: D1Database;
BUILDS: R2Bucket;
JWT_SECRET: string;
LEMONSQUEEZY_SIGNING_SECRET: string;
RESEND_API_KEY: string;
LATEST_VERSION: string;
MAX_ACTIVATIONS: string;
TOKEN_TTL_DAYS: string;
DOWNLOAD_URL_TTL_SECONDS: string;
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
const method = req.method.toUpperCase();
// CORS (utile se in futuro vuoi chiamare l'API dalla landing page)
if (method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Max-Age": "86400",
},
});
}
try {
if (path === "/api/license/activate") {
if (method !== "POST") return methodNotAllowed();
return await handleActivate(req, env);
}
if (path === "/api/license/validate") {
if (method !== "POST") return methodNotAllowed();
return await handleValidate(req, env);
}
if (path === "/api/license/deactivate") {
if (method !== "POST") return methodNotAllowed();
return await handleDeactivate(req, env);
}
if (path === "/api/latest") {
if (method !== "GET") return methodNotAllowed();
return await handleLatest(req, env);
}
if (path === "/api/webhook/lemonsqueezy") {
if (method !== "POST") return methodNotAllowed();
return await handleLemonSqueezyWebhook(req, env);
}
if (path === "/api/health") {
return json({ ok: true, version: env.LATEST_VERSION });
}
return notFound();
} catch (err) {
console.error("Unhandled error:", err);
return json({ error: "Internal error" }, 500);
}
},
};
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
+42
View File
@@ -0,0 +1,42 @@
name = "musictools-api"
main = "src/worker.ts"
compatibility_date = "2026-01-01"
# Routes: musictools.djluza.com/api/* va a questo worker.
# Configurare nel dashboard Cloudflare DNS + Workers Routes
# oppure decommentare se zona gia mappata:
# routes = [
# { pattern = "musictools.djluza.com/api/*", zone_name = "djluza.com" }
# ]
# D1 database (sqlite gestito da Cloudflare).
# Crealo una volta con: npm run db:create
# Poi sostituisci database_id qui sotto con quello restituito.
[[d1_databases]]
binding = "DB"
database_name = "musictools-licenses"
database_id = "REPLACE_AFTER_db:create"
# R2 bucket dove conservi gli zip macOS/Windows.
# Cli: wrangler r2 bucket create musictools-builds
[[r2_buckets]]
binding = "BUILDS"
bucket_name = "musictools-builds"
# KV per rate-limiting (opzionale ma consigliato).
# Cli: wrangler kv:namespace create RATELIMIT
# [[kv_namespaces]]
# binding = "RATELIMIT"
# id = "REPLACE_ME"
# Variabili NON segrete.
[vars]
LATEST_VERSION = "v1.5.2"
MAX_ACTIVATIONS = "3"
TOKEN_TTL_DAYS = "30"
DOWNLOAD_URL_TTL_SECONDS = "300"
# Secrets (impostarli da CLI, NON in chiaro qui):
# wrangler secret put JWT_SECRET # HMAC key per i token offline
# wrangler secret put LEMONSQUEEZY_SIGNING_SECRET # verifica webhook
# wrangler secret put RESEND_API_KEY # invio email license-key