Riscrivi backend per hosting Node/MariaDB/PM2 (no Cloudflare)

L'utente ha un hosting Virtualmin esistente (musictools.djluza.com)
con Apache + Node 20 + MariaDB + PM2. Tutta l'infrastruttura
Cloudflare (Workers/D1/R2/Pages) e' rimpiazzata con un backend
Express che gira sul server gia' presente, a costo zero.

Cambia:
- server/src/*.ts (Workers) -> server/src/*.js (Node ESM puro)
- D1 (sqlite) -> MariaDB 10.11 via mysql2/promise
- R2 (storage) -> filesystem locale ~/builds/ + signed URL HMAC
- wrangler.toml -> ecosystem.config.cjs (PM2)
- Aggiunto src/migrate.js: runner SQL idempotente

Endpoints invariati - l'app desktop non vede differenze:
- POST /api/license/{activate,validate,deactivate}
- GET  /api/latest, /api/download
- POST /api/webhook/lemonsqueezy
- GET  /api/health

README riscritto con istruzioni complete: creazione DB,
deploy via rsync/git, config Apache reverse proxy via
Virtualmin, gestione PM2, backup, workflow release.

Email rimane su Resend (deliverability) - free tier sufficiente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
luzadevandClaude Opus 4.7 committed 2026-06-09 00:15:53 +02:00
1 parent 339dd4f3cc
commit 2d8290579b
22 files changed
+891 -789

No files matched your search

+35
View File
@@ -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 <noreply@djluza.com>
# ---- 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
+2 -2
View File
@@ -1,5 +1,5 @@
node_modules/
.wrangler/
.dev.vars
.env
*.log
logs/
dist/
+186 -59
View File
@@ -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 <subdomain-worker>.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 `</VirtualHost>`:
```apache
# MusicTools API
ProxyPreserveHost On
ProxyRequests Off
# Webhook: passa raw body senza alterazioni
<Location /api/>
ProxyPass http://127.0.0.1:4002/api/
ProxyPassReverse http://127.0.0.1:4002/api/
</Location>
```
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/<version>/`:
```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' <<SQL
INSERT INTO releases (version, platform, file_path, size_bytes, sha256, notes, published_at)
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', UNIX_TIMESTAMP()),
('v1.5.3', 'windows', 'v1.5.3/MusicTools-Windows.zip', 67890, '<sha256>', '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
Aggiungi un cronjob daily:
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
# crontab -e
0 4 * * * mariadb-dump musictools_licenses | gzip > ~/backups/musictools-$(date +\%F).sql.gz && find ~/backups -name 'musictools-*.sql.gz' -mtime +30 -delete
```
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€.
- **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
+31
View File
@@ -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,
},
],
};
+43 -36
View File
@@ -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;
+15 -13
View File
@@ -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"
}
}
+30
View File
@@ -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;
+57
View File
@@ -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 <noreply@djluza.com>" (dominio verificato)
*/
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
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 = `
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
</p>
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
${licenseKey}
</p>
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
<li>Scarica MusicTools per <a href="https://musictools.djluza.com#pricing">Mac o Windows</a></li>
<li>Apri l'app: alla prima schermata ti chiedera' email e chiave</li>
<li>Inserisci questa email (<code>${to}</code>) e la chiave qui sopra</li>
</ol>
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
</div>
`;
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}`);
}
}
-41
View File
@@ -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<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);
}
+51
View File
@@ -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;
}
}
-66
View File
@@ -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<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;
}
}
+83
View File
@@ -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 });
}
-157
View File
@@ -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<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());
}
}
+136
View File
@@ -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("-");
}
-179
View File
@@ -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<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 });
}
+65
View File
@@ -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);
});
+62
View File
@@ -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}`);
});
+95
View File
@@ -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&current=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);
}
-94
View File
@@ -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&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
@@ -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&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
@@ -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"]
}
-42
View File
@@ -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