diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..3832006 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,35 @@ +# Copia questo file in `.env` sul server (non committarlo!) e compila. + +# ---- Server ---- +PORT=4002 +HOST=127.0.0.1 +LATEST_VERSION=v1.5.2 +PUBLIC_BASE_URL=https://musictools.djluza.com + +# ---- DB (MariaDB locale) ---- +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=musictools_licenses +DB_USER=musictools +DB_PASS=cambiami + +# ---- Licenze ---- +JWT_SECRET=cambiami_con_openssl_rand_base64_32 +MAX_ACTIVATIONS=3 +TOKEN_TTL_DAYS=30 +DOWNLOAD_URL_TTL_SECONDS=300 + +# ---- Lemon Squeezy ---- +# Dal dashboard LS: Settings > Webhooks > Signing Secret +LEMONSQUEEZY_SIGNING_SECRET=cambiami + +# ---- Resend ---- +# Dal dashboard Resend: API Keys +RESEND_API_KEY=cambiami +EMAIL_FROM=MusicTools + +# ---- Storage builds ---- +# Dove conservi i binari macOS/Windows da servire agli utenti. +# Default: ../builds rispetto a server/. Puoi metterlo dove vuoi +# purche' il processo Node abbia accesso lettura. +BUILDS_DIR=/home/musictools/builds diff --git a/server/.gitignore b/server/.gitignore index e369fd2..d47ed13 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,5 +1,5 @@ node_modules/ -.wrangler/ -.dev.vars +.env *.log +logs/ dist/ diff --git a/server/README.md b/server/README.md index 667507f..a8b85d4 100644 --- a/server/README.md +++ b/server/README.md @@ -1,51 +1,190 @@ -# MusicTools License & Update API +# MusicTools — License & Update API -Cloudflare Worker che gestisce attivazione licenze, validazione e distribuzione binari MusicTools. +Backend Node.js per gestire 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 +- **Node.js 20 + Express 4** +- **MariaDB 10.11** (locale, localhost:3306) +- **PM2** per process management +- **Apache 2.4** come reverse proxy verso `127.0.0.1:4002` +- **Resend** per email transazionali +- **Lemon Squeezy** per i pagamenti (webhook -> `/api/webhook/lemonsqueezy`) -Una volta sola, da terminale dentro `server/`: +Tutto gira sull'hosting esistente (`musictools@musictools.djluza.com`), zero costi aggiuntivi. + +## Struttura + +``` +server/ + src/ + server.js # Express app + db.js # pool mysql2 + license.js # activate / validate / deactivate + updates.js # /api/latest + /api/download + lemonsqueezy.js # webhook ordini + email.js # Resend client + jwt.js # JWT HS256 senza dipendenze + migrate.js # runner SQL idempotente + migrations/ + 0001_init.sql # schema licenses / activations / releases + ecosystem.config.cjs # PM2 + .env.example + package.json +``` + +## Setup iniziale sul server + +Una volta sola, da SSH `musictools@musictools.djluza.com`: + +### 1) Crea database e utente MariaDB + +Sul server (richiede root o l'utente master DB del tuo hosting — di solito Virtualmin lo crea per te dal pannello "Edit Databases"): + +```sql +CREATE DATABASE musictools_licenses CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'musictools'@'localhost' IDENTIFIED BY 'PASSWORD_FORTE'; +GRANT ALL PRIVILEGES ON musictools_licenses.* TO 'musictools'@'localhost'; +FLUSH PRIVILEGES; +``` + +In alternativa via Virtualmin: **Edit Databases → Create a new database**, poi tab **Manage** → crea utente con password. + +### 2) Clona o sincronizza il codice + +Due opzioni: + +**A. Via git** (consigliato): +```bash +cd ~ +git clone https://github.com/luzadev/musicdownload.git app-src +ln -s app-src/server api +cd api +npm install --production +``` + +**B. Via rsync da locale** (più semplice se preferisci non lasciare codice client sul server): +```bash +# Da Mac: +rsync -avz --exclude node_modules /Users/luciano/Downloads/Progetti2026/MusicDownload/server/ musictools@musictools.djluza.com:~/api/ +# Poi SSH e: +cd ~/api && npm install --production +``` + +### 3) Configura .env ```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 +cd ~/api +cp .env.example .env +nano .env # compila tutti i valori — vedi sotto ``` -## DNS - -Su Cloudflare Dashboard > djluza.com > DNS aggiungi: - -``` -musictools CNAME .workers.dev proxied +Per generare `JWT_SECRET`: +```bash +openssl rand -base64 32 ``` -Poi vai su Workers & Pages > musictools-api > Settings > Triggers > Custom Domains -e aggiungi `musictools.djluza.com`. +### 4) Migrate + +```bash +cd ~/api +npm run migrate +``` + +Output atteso: +``` +[migrate] applico 0001_init.sql +[migrate] ok 0001_init.sql +[migrate] done +``` + +### 5) Avvia con PM2 + +```bash +cd ~/api +mkdir -p logs +pm2 start ecosystem.config.cjs +pm2 save +# Test: +curl -s http://127.0.0.1:4002/api/health +# -> {"ok":true,"version":"v1.5.2"} +``` + +### 6) Apache reverse proxy + +Devi dire ad Apache che le richieste a `musictools.djluza.com/api/*` vanno a `127.0.0.1:4002`. + +**Via Virtualmin** (consigliato): + +1. Vai su **Webmin → Servers → Apache Webserver** +2. Clicca sul VirtualHost di `musictools.djluza.com` (porta 443) +3. Sezione **Aliases and redirects** o **Edit Directives**, aggiungi prima di ``: + +```apache +# MusicTools API +ProxyPreserveHost On +ProxyRequests Off + +# Webhook: passa raw body senza alterazioni + + ProxyPass http://127.0.0.1:4002/api/ + ProxyPassReverse http://127.0.0.1:4002/api/ + +``` + +4. Apply changes. + +**Verifica moduli Apache attivi** (da SSH se hai sudo): +```bash +apache2ctl -M | grep -E 'proxy|proxy_http' +# Devono comparire proxy_module e proxy_http_module +``` +Se mancano, abilitali (sudo richiesto): +```bash +sudo a2enmod proxy proxy_http +sudo systemctl reload apache2 +``` + +### 7) Verifica end-to-end + +Da qualsiasi posto: +```bash +curl -s https://musictools.djluza.com/api/health +# -> {"ok":true,"version":"v1.5.2"} +``` + +## Workflow pubblicazione release + +1. GitHub Actions builda i due zip (gia in place). +2. Carica gli zip sul server in `~/builds//`: + ```bash + ssh musictools@musictools.djluza.com 'mkdir -p ~/builds/v1.5.3' + scp MusicTools-macOS.zip musictools@musictools.djluza.com:~/builds/v1.5.3/ + scp MusicTools-Windows.zip musictools@musictools.djluza.com:~/builds/v1.5.3/ + ``` +3. Inserisci il record in MariaDB: + ```bash + ssh musictools@musictools.djluza.com 'mariadb musictools_licenses' <', 'Note...', UNIX_TIMESTAMP()), + ('v1.5.3', 'windows', 'v1.5.3/MusicTools-Windows.zip', 67890, '', 'Note...', UNIX_TIMESTAMP()); + SQL + ``` +4. Aggiorna `LATEST_VERSION` nel `.env` e `pm2 reload musictools-api`. + +In futuro: script o workflow GitHub Actions che fa tutto in automatico. + +## Operazioni quotidiane + +| Cosa | Comando | +|---|---| +| Reload codice dopo deploy | `pm2 reload musictools-api` | +| Vedere log live | `pm2 logs musictools-api` | +| Stato | `pm2 status` | +| Errori recenti | `pm2 logs musictools-api --err --lines 100` | +| Restart hard | `pm2 restart musictools-api` | +| Stop | `pm2 stop musictools-api` | +| Console DB | `mariadb -u musictools -p musictools_licenses` | ## Endpoints @@ -55,34 +194,22 @@ e aggiungi `musictools.djluza.com`. | 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 | +| GET | `/api/download?file=…&exp=…&sig=…` | Firma HMAC | Stream del binario | | POST | `/api/webhook/lemonsqueezy` | X-Signature HMAC | Crea licenza dopo ordine | | GET | `/api/health` | — | Healthcheck | -## Workflow pubblicazione release +## Backup -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, '', 'Note...', strftime('%s','now')); - ``` - (eseguibile da `npx wrangler d1 execute musictools-licenses --remote --command "..."`) +Aggiungi un cronjob daily: -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 +```bash +# crontab -e +0 4 * * * mariadb-dump musictools_licenses | gzip > ~/backups/musictools-$(date +\%F).sql.gz && find ~/backups -name 'musictools-*.sql.gz' -mtime +30 -delete +``` ## Costi -A 0 vendite: **0€/mese** (tutto in free tier). -A 100 vendite/mese: ~5€ Lemon Squeezy commission + 0€ Cloudflare = ~5€. +- **Hosting**: gia pagato (server condiviso esistente) +- **Lemon Squeezy**: 5% + $0.50 per transazione = ~5 EUR su un acquisto da 39,90 EUR +- **Resend**: free fino a 3k email/mese (= ~3k licenze/mese, oltre serve piano $20) +- **Totale fisso**: 0 EUR/mese diff --git a/server/ecosystem.config.cjs b/server/ecosystem.config.cjs new file mode 100644 index 0000000..c78ffd0 --- /dev/null +++ b/server/ecosystem.config.cjs @@ -0,0 +1,31 @@ +/** + * PM2 ecosystem per il backend MusicTools. + * + * Avvio: pm2 start ecosystem.config.cjs --env production + * Reload: pm2 reload musictools-api + * Logs: pm2 logs musictools-api + * Save: pm2 save && pm2 startup (al primo deploy, una sola volta) + */ + +module.exports = { + apps: [ + { + name: "musictools-api", + script: "src/server.js", + cwd: __dirname, + exec_mode: "fork", // 1 processo, basta per i nostri volumi + instances: 1, + autorestart: true, + max_restarts: 10, + max_memory_restart: "256M", + env: { + NODE_ENV: "production", + }, + // Le env sensibili stanno in .env: dotenv le carica al boot + out_file: "logs/out.log", + error_file: "logs/err.log", + merge_logs: true, + time: true, + }, + ], +}; diff --git a/server/migrations/0001_init.sql b/server/migrations/0001_init.sql index 6fe362e..9ff9acb 100644 --- a/server/migrations/0001_init.sql +++ b/server/migrations/0001_init.sql @@ -1,42 +1,49 @@ --- Schema iniziale licenze MusicTools +-- Schema MariaDB iniziale per MusicTools licenze. +-- Tutto utf8mb4 + InnoDB con FK abilitate. + +SET NAMES utf8mb4; 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); + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + license_key VARCHAR(64) NOT NULL, + email VARCHAR(255) NOT NULL, + status ENUM('active','revoked','refunded') NOT NULL DEFAULT 'active', + source VARCHAR(32) NULL, + order_id VARCHAR(64) NULL, + created_at BIGINT UNSIGNED NOT NULL, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_licenses_key (license_key), + KEY idx_licenses_email (email), + KEY idx_licenses_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 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); + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + license_id INT UNSIGNED NOT NULL, + device_id VARCHAR(64) NOT NULL, + device_name VARCHAR(255) NULL, + app_version VARCHAR(32) NULL, + activated_at BIGINT UNSIGNED NOT NULL, + last_seen_at BIGINT UNSIGNED NOT NULL, + revoked_at BIGINT UNSIGNED NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_activations_lic_dev (license_id, device_id), + KEY idx_activations_license (license_id), + CONSTRAINT fk_activations_license + FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; 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); + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + version VARCHAR(32) NOT NULL, + platform ENUM('macos','windows') NOT NULL, + file_path VARCHAR(512) NOT NULL, + size_bytes BIGINT UNSIGNED NULL, + sha256 CHAR(64) NULL, + notes TEXT NULL, + published_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_releases_ver_plat (version, platform), + KEY idx_releases_platform_pub (platform, published_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/package.json b/server/package.json index 5901b9e..f7fb2b4 100644 --- a/server/package.json +++ b/server/package.json @@ -1,19 +1,21 @@ { - "name": "musictools-license-server", - "version": "0.1.0", + "name": "musictools-api", + "version": "1.0.0", "private": true, - "description": "License + update API for MusicTools (Cloudflare Workers + D1)", + "type": "module", + "description": "License + update API per MusicTools (Node.js + Express + MariaDB)", + "main": "src/server.js", "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" + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "migrate": "node src/migrate.js" }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20251101.0", - "typescript": "^5.6.0", - "wrangler": "^3.95.0" + "engines": { + "node": ">=20" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.21.0", + "mysql2": "^3.11.0" } } diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..d5748e1 --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,30 @@ +import mysql from "mysql2/promise"; + +const pool = mysql.createPool({ + host: process.env.DB_HOST || "127.0.0.1", + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER, + password: process.env.DB_PASS, + database: process.env.DB_NAME, + waitForConnections: true, + connectionLimit: 5, + queueLimit: 0, + charset: "utf8mb4", +}); + +export async function query(sql, params = []) { + const [rows] = await pool.execute(sql, params); + return rows; +} + +export async function one(sql, params = []) { + const rows = await query(sql, params); + return rows[0] || null; +} + +export async function exec(sql, params = []) { + const [result] = await pool.execute(sql, params); + return result; // { insertId, affectedRows, ... } +} + +export default pool; diff --git a/server/src/email.js b/server/src/email.js new file mode 100644 index 0000000..d986fb9 --- /dev/null +++ b/server/src/email.js @@ -0,0 +1,57 @@ +/** + * Invio email transazionali via Resend (https://resend.com). + * Usa fetch nativo di Node >= 20 — nessuna dipendenza. + * + * Variabili env richieste: + * RESEND_API_KEY dal dashboard Resend + * EMAIL_FROM "MusicTools " (dominio verificato) + */ + +const FROM = process.env.EMAIL_FROM || "MusicTools "; + +export async function sendLicenseEmail(to, licenseKey) { + if (!process.env.RESEND_API_KEY) { + console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to); + return; + } + + const html = ` +
+

Grazie per aver scelto MusicTools!

+

+ Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app. +

+

+ ${licenseKey} +

+

Come attivare:

+
    +
  1. Scarica MusicTools per Mac o Windows
  2. +
  3. Apri l'app: alla prima schermata ti chiedera' email e chiave
  4. +
  5. Inserisci questa email (${to}) e la chiave qui sopra
  6. +
+

Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).

+
+

Hai problemi? Scrivici a info@djluza.com

+
+ `; + + const resp = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: FROM, + to: [to], + subject: "La tua licenza MusicTools", + html, + }), + }); + + if (!resp.ok) { + const txt = await resp.text(); + throw new Error(`Resend ${resp.status}: ${txt}`); + } +} diff --git a/server/src/http.ts b/server/src/http.ts deleted file mode 100644 index 9f51fc7..0000000 --- a/server/src/http.ts +++ /dev/null @@ -1,41 +0,0 @@ -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(req: Request): Promise { - 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); -} diff --git a/server/src/jwt.js b/server/src/jwt.js new file mode 100644 index 0000000..1585218 --- /dev/null +++ b/server/src/jwt.js @@ -0,0 +1,51 @@ +/** + * JWT HS256 minimale (niente dipendenze). Stessa logica del worker + * Cloudflare precedente: stesso token formato, stessa firma, stessa + * verifica. L'app desktop non distingue. + */ + +import crypto from "node:crypto"; + +function b64url(buf) { + return Buffer.from(buf).toString("base64") + .replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function b64urlDecode(s) { + s = s.replace(/-/g, "+").replace(/_/g, "/"); + s += "=".repeat((4 - (s.length % 4)) % 4); + return Buffer.from(s, "base64"); +} + +function hmac(secret, data) { + return crypto.createHmac("sha256", secret).update(data).digest(); +} + +export function signJwt(claims, secret) { + const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const body = b64url(JSON.stringify(claims)); + const sig = b64url(hmac(secret, `${head}.${body}`)); + return `${head}.${body}.${sig}`; +} + +export function verifyJwt(token, secret) { + if (typeof token !== "string") return null; + const parts = token.split("."); + if (parts.length !== 3) return null; + const [head, body, sig] = parts; + const expected = b64url(hmac(secret, `${head}.${body}`)); + // confronto a tempo costante + if (sig.length !== expected.length) return null; + let diff = 0; + for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i); + if (diff !== 0) return null; + try { + const claims = JSON.parse(b64urlDecode(body).toString("utf-8")); + if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) { + return null; + } + return claims; + } catch { + return null; + } +} diff --git a/server/src/jwt.ts b/server/src/jwt.ts deleted file mode 100644 index a9c64a5..0000000 --- a/server/src/jwt.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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 { - 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 { - 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 { - 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; - } -} diff --git a/server/src/lemonsqueezy.js b/server/src/lemonsqueezy.js new file mode 100644 index 0000000..79ffd4f --- /dev/null +++ b/server/src/lemonsqueezy.js @@ -0,0 +1,83 @@ +/** + * Webhook Lemon Squeezy. + * URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy + * Eventi gestiti: order_created, order_refunded. + */ + +import crypto from "node:crypto"; +import { one, exec } from "./db.js"; +import { generateLicenseKey } from "./license.js"; +import { sendLicenseEmail } from "./email.js"; + +const now = () => Math.floor(Date.now() / 1000); + +function hmacHex(secret, data) { + return crypto.createHmac("sha256", secret).update(data).digest("hex"); +} + +function timingSafe(a, b) { + if (a.length !== b.length) return false; + const A = Buffer.from(a), B = Buffer.from(b); + return crypto.timingSafeEqual(A, B); +} + +export async function webhook(req, res) { + // express.raw() salva il body come Buffer in req.body + const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : ""; + const sig = req.get("X-Signature") || ""; + const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET; + if (!sig || !secret) return res.status(400).json({ error: "Missing signature" }); + + const expected = hmacHex(secret, raw); + if (!timingSafe(sig, expected)) { + return res.status(401).json({ error: "Invalid signature" }); + } + + let payload; + try { payload = JSON.parse(raw); } + catch { return res.status(400).json({ error: "Invalid JSON" }); } + + const eventName = payload?.meta?.event_name || ""; + const attrs = payload?.data?.attributes || {}; + const email = String(attrs.user_email || "").trim().toLowerCase(); + const orderId = String(payload?.data?.id || attrs.order_number || ""); + if (!email || !orderId) { + return res.status(400).json({ error: "Missing email or order_id" }); + } + + const t = now(); + + if (eventName === "order_created") { + const key = generateLicenseKey(); + try { + await exec( + `INSERT INTO licenses + (license_key, email, status, source, order_id, created_at, updated_at) + VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`, + [key, email, orderId, t, t], + ); + } catch (e) { + // duplicate webhook delivery + if (e?.code === "ER_DUP_ENTRY") { + return res.json({ ok: true, duplicate: true }); + } + throw e; + } + try { + await sendLicenseEmail(email, key); + } catch (e) { + console.error("[email] send failed:", e?.message || e); + } + return res.json({ ok: true }); + } + + if (eventName === "order_refunded") { + await exec( + `UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`, + [t, orderId], + ); + return res.json({ ok: true }); + } + + res.json({ ok: true, ignored: eventName }); +} diff --git a/server/src/lemonsqueezy.ts b/server/src/lemonsqueezy.ts deleted file mode 100644 index c9fbd9f..0000000 --- a/server/src/lemonsqueezy.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * 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 }; - 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 { - 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 { - 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 { - if (!env.RESEND_API_KEY) { - console.warn("RESEND_API_KEY non impostata, skip invio email"); - return; - } - const body = { - from: "MusicTools ", - to: [email], - subject: "La tua licenza MusicTools", - html: ` -
-

Grazie per aver scelto MusicTools!

-

Ecco la tua chiave di licenza:

-

- ${key} -

-

Per attivarla:

-
    -
  1. Scarica MusicTools per macOS o Windows
  2. -
  3. Apri l'app: ti chiedera' email e chiave
  4. -
  5. Inserisci questa email (${email}) e la chiave qui sopra
  6. -
-

Puoi attivare la licenza fino a 3 dispositivi.

-
-

Hai problemi? Scrivici a info@djluza.com

-
- `, - }; - 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()); - } -} diff --git a/server/src/license.js b/server/src/license.js new file mode 100644 index 0000000..4071bd9 --- /dev/null +++ b/server/src/license.js @@ -0,0 +1,136 @@ +import crypto from "node:crypto"; +import { one, exec } from "./db.js"; +import { signJwt, verifyJwt } from "./jwt.js"; + +const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3); +const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30); + +const now = () => Math.floor(Date.now() / 1000); +const normEmail = (s) => String(s || "").trim().toLowerCase(); +const normKey = (s) => String(s || "").trim().toUpperCase(); + +async function issueToken(license, deviceId) { + const t = now(); + return signJwt({ + sub: String(license.id), + key_id: license.license_key, + email: license.email, + device_id: deviceId, + iat: t, + exp: t + TOKEN_TTL_DAYS * 86400, + }, process.env.JWT_SECRET); +} + +export async function activate(req, res) { + const { key, email, device_id, device_name, app_version } = req.body || {}; + const K = normKey(key), E = normEmail(email); + const D = String(device_id || "").trim(); + if (!K || !E || !D) { + return res.status(400).json({ error: "Missing key, email or device_id" }); + } + + const license = await one( + "SELECT id, license_key, email, status FROM licenses WHERE license_key=? AND email=? LIMIT 1", + [K, E], + ); + if (!license) { + return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." }); + } + if (license.status !== "active") { + return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." }); + } + + const t = now(); + const existing = await one( + "SELECT id FROM activations WHERE license_id=? AND device_id=? LIMIT 1", + [license.id, D], + ); + + if (existing) { + await exec( + `UPDATE activations SET app_version=?, device_name=?, last_seen_at=?, revoked_at=NULL + WHERE id=?`, + [app_version || null, device_name || null, t, existing.id], + ); + } else { + const row = await one( + "SELECT COUNT(*) AS n FROM activations WHERE license_id=? AND revoked_at IS NULL", + [license.id], + ); + if ((row?.n || 0) >= MAX_ACTIVATIONS) { + return res.status(409).json({ + error: `Hai gia attivato la licenza su ${MAX_ACTIVATIONS} dispositivi. Disattivane uno per usarla qui.`, + }); + } + await exec( + `INSERT INTO activations + (license_id, device_id, device_name, app_version, activated_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [license.id, D, device_name || null, app_version || null, t, t], + ); + } + + const token = await issueToken(license, D); + res.json({ token, activated_at: t, email: license.email }); +} + +export async function validate(req, res) { + const { token, device_id, app_version } = req.body || {}; + const T = String(token || "").trim(); + const D = String(device_id || "").trim(); + if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" }); + + const claims = verifyJwt(T, process.env.JWT_SECRET); + if (!claims) return res.status(401).json({ error: "Token invalido o scaduto" }); + if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" }); + + const license = await one( + "SELECT id, license_key, email, status FROM licenses WHERE id=?", + [claims.sub], + ); + if (!license || license.status !== "active") { + return res.status(401).json({ error: "Licenza non attiva" }); + } + const act = await one( + "SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?", + [license.id, D], + ); + if (!act || act.revoked_at !== null) { + return res.status(401).json({ error: "Attivazione non trovata o revocata" }); + } + + await exec( + "UPDATE activations SET last_seen_at=?, app_version=? WHERE id=?", + [now(), app_version || null, act.id], + ); + + const fresh = await issueToken(license, D); + res.json({ token: fresh, email: license.email }); +} + +export async function deactivate(req, res) { + const { token, device_id } = req.body || {}; + const T = String(token || "").trim(); + const D = String(device_id || "").trim(); + if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" }); + + const claims = verifyJwt(T, process.env.JWT_SECRET); + if (!claims) return res.status(401).json({ error: "Token invalido" }); + if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" }); + + await exec( + `UPDATE activations SET revoked_at=? + WHERE license_id=? AND device_id=? AND revoked_at IS NULL`, + [now(), claims.sub, D], + ); + res.json({ ok: true }); +} + +// Esposto per uso interno (es. webhook genera chiave nuova) +export function generateLicenseKey() { + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const buf = crypto.randomBytes(16); + 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("-"); +} diff --git a/server/src/license.ts b/server/src/license.ts deleted file mode 100644 index 51b3924..0000000 --- a/server/src/license.ts +++ /dev/null @@ -1,179 +0,0 @@ -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 { - 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(); -} - -async function countActiveActivations(env: Env, licenseId: number): Promise { - 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 { - return await env.DB.prepare( - `SELECT * FROM activations - WHERE license_id = ?1 AND device_id = ?2 LIMIT 1` - ).bind(licenseId, deviceId).first(); -} - -async function issueToken(env: Env, license: LicenseRow, deviceId: string): Promise { - 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 { - 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 { - 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(); - 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 { - 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 }); -} diff --git a/server/src/migrate.js b/server/src/migrate.js new file mode 100644 index 0000000..b6ad785 --- /dev/null +++ b/server/src/migrate.js @@ -0,0 +1,65 @@ +/** + * Applica i file SQL in migrations/ in ordine alfabetico. + * Tiene traccia dei file gia eseguiti in una tabella `schema_migrations`. + * + * Run: npm run migrate + */ + +import "dotenv/config"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pool, { query, exec } from "./db.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIG_DIR = path.resolve(__dirname, "../migrations"); + +async function ensureMigrationsTable() { + await exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename VARCHAR(255) NOT NULL PRIMARY KEY, + applied_at BIGINT UNSIGNED NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `); +} + +async function run() { + await ensureMigrationsTable(); + const applied = new Set( + (await query("SELECT filename FROM schema_migrations")).map((r) => r.filename), + ); + + const files = fs.readdirSync(MIG_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + + for (const f of files) { + if (applied.has(f)) { + console.log(`[migrate] skip ${f} (gia applicato)`); + continue; + } + const sql = fs.readFileSync(path.join(MIG_DIR, f), "utf-8"); + console.log(`[migrate] applico ${f}`); + // mysql2 supporta multipleStatements ma e' rischioso; splittiamo manualmente. + const statements = sql + .split(/;\s*\n/) + .map((s) => s.trim()) + .filter((s) => s.length > 0 && !s.startsWith("--")); + for (const stmt of statements) { + await exec(stmt); + } + await exec( + "INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)", + [f, Math.floor(Date.now() / 1000)], + ); + console.log(`[migrate] ok ${f}`); + } + + await pool.end(); + console.log("[migrate] done"); +} + +run().catch((e) => { + console.error("[migrate] errore:", e); + process.exit(1); +}); diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..e4df30a --- /dev/null +++ b/server/src/server.js @@ -0,0 +1,62 @@ +import "dotenv/config"; +import express from "express"; + +import * as license from "./license.js"; +import * as updates from "./updates.js"; +import * as ls from "./lemonsqueezy.js"; + +const app = express(); + +// L'app sta dietro Apache reverse proxy: fidati di X-Forwarded-* dal localhost. +app.set("trust proxy", "loopback"); +app.disable("x-powered-by"); + +// CORS minimale (solo per /api/*) +app.use("/api", (req, res, next) => { + res.set("Access-Control-Allow-Origin", "*"); + res.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + res.set("Access-Control-Allow-Headers", "Content-Type,Authorization"); + if (req.method === "OPTIONS") return res.status(204).end(); + next(); +}); + +// Health check (no body parser necessario) +app.get("/api/health", (_req, res) => { + res.json({ ok: true, version: process.env.LATEST_VERSION || "" }); +}); + +// WEBHOOK Lemon Squeezy: deve ricevere il body RAW per verificare la firma. +// Va registrato PRIMA del json parser globale. +app.post( + "/api/webhook/lemonsqueezy", + express.raw({ type: "application/json", limit: "1mb" }), + ls.webhook, +); + +// JSON parser per tutti gli altri endpoint +app.use(express.json({ limit: "128kb" })); + +// Licenze +app.post("/api/license/activate", license.activate); +app.post("/api/license/validate", license.validate); +app.post("/api/license/deactivate", license.deactivate); + +// Aggiornamenti + download firmato +app.get("/api/latest", updates.latest); +app.get("/api/download", updates.download); + +// 404 JSON solo per /api/* +app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" })); + +// Error handler +app.use((err, _req, res, _next) => { + console.error("[unhandled]", err); + if (res.headersSent) return; + res.status(500).json({ error: "Internal error" }); +}); + +const PORT = Number(process.env.PORT || 4002); +const HOST = process.env.HOST || "127.0.0.1"; +app.listen(PORT, HOST, () => { + console.log(`[musictools-api] listening on ${HOST}:${PORT}`); +}); diff --git a/server/src/updates.js b/server/src/updates.js new file mode 100644 index 0000000..8d2802a --- /dev/null +++ b/server/src/updates.js @@ -0,0 +1,95 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { one } from "./db.js"; +import { verifyJwt } from "./jwt.js"; + +const BUILDS_DIR = process.env.BUILDS_DIR || path.resolve(process.cwd(), "../builds"); +const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300); +const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com"; + +function signPayload(payload, secret) { + return crypto.createHmac("sha256", secret).update(payload).digest("base64url"); +} + +function buildDownloadUrl(filePath) { + const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL; + const sig = signPayload(`${filePath}.${exp}`, process.env.JWT_SECRET); + return `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(filePath)}&exp=${exp}&sig=${sig}`; +} + +// GET /api/latest?platform=macos|windows¤t=v1.5.2 +export async function latest(req, res) { + const platform = String(req.query.platform || "").toLowerCase(); + if (platform !== "macos" && platform !== "windows") { + return res.status(400).json({ error: "platform must be macos or windows" }); + } + + const row = await one( + `SELECT version, platform, file_path, size_bytes, sha256, notes, published_at + FROM releases + WHERE platform=? + ORDER BY published_at DESC + LIMIT 1`, + [platform], + ); + + if (!row) { + return res.json({ + version: process.env.LATEST_VERSION || "", + notes: "", + download_url: "", + requires_license: true, + }); + } + + // Auth opzionale (Bearer): senza, niente download URL + const auth = req.get("Authorization") || ""; + let licensed = false; + if (auth.startsWith("Bearer ")) { + const claims = verifyJwt(auth.slice(7).trim(), process.env.JWT_SECRET); + licensed = !!claims; + } + + res.json({ + version: row.version, + notes: row.notes || "", + sha256: row.sha256 || "", + size_bytes: Number(row.size_bytes || 0), + download_url: licensed ? buildDownloadUrl(row.file_path) : "", + requires_license: !licensed, + }); +} + +// GET /api/download?file=...&exp=...&sig=... +// Verifica firma e stream del file da disco. +export async function download(req, res) { + const file = String(req.query.file || ""); + const exp = Number(req.query.exp || 0); + const sig = String(req.query.sig || ""); + if (!file || !exp || !sig) return res.status(400).send("Missing params"); + + const expected = signPayload(`${file}.${exp}`, process.env.JWT_SECRET); + const a = Buffer.from(sig), b = Buffer.from(expected); + if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { + return res.status(401).send("Invalid signature"); + } + if (exp < Math.floor(Date.now() / 1000)) { + return res.status(410).send("URL expired"); + } + + // Sicurezza path: il file deve trovarsi sotto BUILDS_DIR. + // 'file' arriva come "v1.5.3/MusicTools-macOS.zip" + const abs = path.resolve(BUILDS_DIR, file); + if (!abs.startsWith(path.resolve(BUILDS_DIR) + path.sep)) { + return res.status(403).send("Forbidden"); + } + if (!fs.existsSync(abs)) { + return res.status(404).send("File not found"); + } + + const name = path.basename(abs); + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", `attachment; filename="${name}"`); + fs.createReadStream(abs).pipe(res); +} diff --git a/server/src/updates.ts b/server/src/updates.ts deleted file mode 100644 index 8bba596..0000000 --- a/server/src/updates.ts +++ /dev/null @@ -1,94 +0,0 @@ -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¤t=v1.5.2 - * Authorization: Bearer (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 { - 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(); - - 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=&exp=&sig= - 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 { - // 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}`; -} diff --git a/server/src/worker.ts b/server/src/worker.ts deleted file mode 100644 index 4ded2bd..0000000 --- a/server/src/worker.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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¤t=v1.5.2 [Authorization: Bearer ] - * 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 { - 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); - } - }, -}; diff --git a/server/tsconfig.json b/server/tsconfig.json deleted file mode 100644 index 97252db..0000000 --- a/server/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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"] -} diff --git a/server/wrangler.toml b/server/wrangler.toml deleted file mode 100644 index 1fc8b33..0000000 --- a/server/wrangler.toml +++ /dev/null @@ -1,42 +0,0 @@ -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