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:
1 parent
339dd4f3cc
commit
2d8290579b
22 files changed
+892
-790
No files matched your search
@@ -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
@@ -1,5 +1,5 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
.env
|
||||
*.log
|
||||
logs/
|
||||
dist/
|
||||
+187
-60
@@ -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
|
||||
|
||||
1. GitHub Actions builda macOS e Windows zip (gia in place).
|
||||
2. Step manuale (per ora): scarica i due zip, caricali su R2:
|
||||
```bash
|
||||
npx wrangler r2 object put musictools-builds/v1.5.3/MusicTools-macOS.zip --file=MusicTools-macOS.zip
|
||||
npx wrangler r2 object put musictools-builds/v1.5.3/MusicTools-Windows.zip --file=MusicTools-Windows.zip
|
||||
```
|
||||
3. Inserisci il record `releases`:
|
||||
```sql
|
||||
INSERT INTO releases (version, platform, r2_key, size_bytes, sha256, notes, published_at)
|
||||
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', strftime('%s','now'));
|
||||
```
|
||||
(eseguibile da `npx wrangler d1 execute musictools-licenses --remote --command "..."`)
|
||||
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
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("-");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <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}`;
|
||||
}
|
||||
@@ -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 <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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user