Setup commercial launch: licenze + backend Cloudflare Worker

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

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

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

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

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

No files matched your search

+6
View File
@@ -43,3 +43,9 @@ PlayList_da_scaricare.txt
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
assets/icon.iconset/
# Backend (server/) build artifacts e secrets
server/node_modules/
server/.wrangler/
server/.dev.vars
server/dist/
+77 -30
View File
@@ -10,7 +10,8 @@ import time
import webbrowser
from typing import Any, Optional
from core.config import load_config, save_config, VERSION
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
from core import license as license_mod
from core.downloader import (
download_playlist,
download_direct_url,
@@ -113,10 +114,15 @@ class Api:
def get_init_data(self) -> dict:
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
cfg = load_config()
# Non spediamo il token raw al frontend (e' sensibile e non serve in UI)
safe_cfg = {k: v for k, v in cfg.items() if k != "license_token"}
license_status = license_mod.get_status(cfg)
return {
"version": VERSION,
"config": cfg,
"config": safe_cfg,
"spotify_guide": SPOTIFY_GUIDE_TEXT,
"license": license_status,
"purchase_url": "https://musictools.djluza.com",
}
# ------------------------------------------------------------------
@@ -128,7 +134,10 @@ class Api:
except (TypeError, ValueError):
threshold = 310
config = {
# Merge sul config esistente per preservare i campi licenza
# (license_key/token/email/...), device_id, ecc.
config = load_config()
config.update({
"client_id": (payload.get("client_id") or "").strip(),
"client_secret": (payload.get("client_secret") or "").strip(),
"bitrate": payload.get("bitrate", "320K"),
@@ -136,40 +145,73 @@ class Api:
"cookies_path": (payload.get("cookies_path") or "").strip(),
"output_dir": (payload.get("output_dir") or "").strip(),
"theme": payload.get("theme", "dark"),
}
})
save_config(config)
return {"ok": True}
def check_update(self) -> dict:
"""Controlla aggiornamenti interrogando l'API GitHub Releases."""
import platform
import requests
# ------------------------------------------------------------------
# Licenza
# ------------------------------------------------------------------
def get_license_status(self) -> dict:
return license_mod.get_status()
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
def activate_license(self, payload: dict) -> dict:
try:
resp = requests.get(API_URL, headers={
"User-Agent": "MusicTools",
"Accept": "application/vnd.github+json",
}, timeout=10)
resp.raise_for_status()
data = resp.json()
status = license_mod.activate(
payload.get("key") or "",
payload.get("email") or "",
)
return {"ok": True, "license": status}
except license_mod.LicenseNetworkError as e:
return {"ok": False, "error": f"Impossibile contattare il server: {e}"}
except license_mod.LicenseError as e:
return {"ok": False, "error": str(e)}
remote = data.get("tag_name", "")
notes = data.get("body", "") or ""
html_url = data.get("html_url", "")
def deactivate_license(self) -> dict:
status = license_mod.deactivate(release_remote=True)
return {"ok": True, "license": status}
# Trova l'asset giusto per la piattaforma
assets = data.get("assets", []) or []
def revalidate_license(self) -> dict:
"""Revalidate in foreground (chiamabile dalla UI 'Verifica ora')."""
status = license_mod.validate()
return {"ok": True, "license": status}
def open_purchase_page(self) -> dict:
webbrowser.open("https://musictools.djluza.com")
return {"ok": True}
def check_update(self) -> dict:
"""Controlla aggiornamenti interrogando l'API djluza.com.
Il server identifica la piattaforma e restituisce un URL di
download firmato a tempo. Senza un token di licenza valido
ritorna comunque la versione corrente ma con download_url vuoto.
"""
import platform
import urllib.error
import urllib.request
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
is_macos = platform.system() == "Darwin"
keyword = "macos" if is_macos else "windows"
download_url = ""
for a in assets:
name = (a.get("name") or "").lower()
if keyword in name and name.endswith(".zip"):
download_url = a.get("browser_download_url", "")
break
if not download_url:
download_url = html_url # fallback: pagina release
plat = "macos" if is_macos else "windows"
url = f"{LICENSE_API_URL.rstrip('/')}/api/latest?platform={plat}&current={VERSION}"
headers = {
"User-Agent": f"MusicTools/{VERSION}",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
remote = data.get("version", "")
notes = data.get("notes", "") or ""
download_url = data.get("download_url", "") or ""
return {
"ok": True,
@@ -177,8 +219,13 @@ class Api:
"remote": remote,
"is_new": bool(remote and remote != VERSION),
"download_url": download_url,
"notes": notes[:500] if notes else "", # tronca per UI
"notes": notes[:500] if notes else "",
"requires_license": not bool(download_url) and not token,
}
except urllib.error.HTTPError as e:
return {"ok": False, "error": f"HTTP {e.code}: {e.reason}", "current": VERSION}
except (urllib.error.URLError, TimeoutError, OSError) as e:
return {"ok": False, "error": f"Connessione fallita: {e}", "current": VERSION}
except Exception as e:
return {"ok": False, "error": str(e), "current": VERSION}
+18
View File
@@ -11,6 +11,17 @@ VERSION = "v1.5.2"
APP_NAME = "MusicTools"
_LEGACY_NAME = "MusicDownload"
# Endpoint dell'API di licenza/aggiornamenti. Cambialo qui per puntare
# a un ambiente di staging.
LICENSE_API_URL = "https://musictools.djluza.com"
# Quanti giorni puo' restare l'app offline prima di richiedere una
# nuova validazione contro il server.
LICENSE_GRACE_DAYS = 14
# Ogni quanti giorni l'app rivalida la licenza in background quando online.
LICENSE_REVALIDATE_DAYS = 7
def _get_config_dir() -> Path:
"""Ritorna la directory per config.json.
@@ -59,6 +70,13 @@ DEFAULTS = {
"cookies_path": str(_project_dir / "cookies.txt"),
"output_dir": str(_project_dir / "MUSICA"),
"theme": "dark",
# ---- Licenza ----
"license_key": "", # chiave fornita all'utente via email
"license_email": "", # email associata all'acquisto
"license_token": "", # JWT firmato dal server (claims offline)
"license_activated_at": 0, # epoch della prima attivazione
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
"device_id": "", # UUID generato al primo avvio
}
+302
View File
@@ -0,0 +1,302 @@
"""Gestione licenze MusicTools.
Flusso:
1. L'utente compra su musictools.djluza.com -> riceve license_key via email.
2. Al primo avvio l'app mostra schermata bloccante: chiede email + key.
3. activate() chiama POST {LICENSE_API_URL}/api/license/activate
passando key, email, device_id (+ device_name). Il server:
- verifica che la key esista e non abbia superato max attivazioni;
- registra il device_id come attivo;
- ritorna un JWT con claims {sub: license_id, exp, email, key_id}.
4. Il token viene salvato in config.json. Il client lo considera valido
per LICENSE_REVALIDATE_DAYS senza ricontrollare il server; oltre
LICENSE_GRACE_DAYS chiede sempre nuova validazione online.
5. validate() chiama POST /api/license/validate con {token, device_id}
in background. Il server puo' revocare (refund, abuse) ritornando
401 -> il client cancella il token e torna a schermata attivazione.
Nota: non verifichiamo la firma JWT lato client (servirebbe la public
key). Ci fidiamo del token perche' e' uscito dal server al momento
dell'attivazione, e ogni N giorni lo facciamo rivalidare. Per i refund
non immediati basta il check periodico.
"""
from __future__ import annotations
import base64
import json
import platform
import time
import urllib.error
import urllib.request
import uuid
from typing import Optional
from core.config import (
LICENSE_API_URL,
LICENSE_GRACE_DAYS,
LICENSE_REVALIDATE_DAYS,
VERSION,
load_config,
save_config,
)
_HTTP_TIMEOUT = 15 # secondi
# ============================================================
# Errori
# ============================================================
class LicenseError(Exception):
"""Errore di attivazione/validazione licenza."""
class LicenseNetworkError(LicenseError):
"""Impossibile raggiungere il server (offline, DNS, ecc.)."""
# ============================================================
# Utility
# ============================================================
def _now() -> int:
return int(time.time())
def _ensure_device_id(config: dict) -> str:
"""Garantisce che config abbia un device_id stabile e univoco."""
did = (config.get("device_id") or "").strip()
if not did:
did = str(uuid.uuid4())
config["device_id"] = did
save_config(config)
return did
def _device_name() -> str:
"""Nome leggibile per identificare il device lato server (UI utente)."""
try:
return f"{platform.node() or 'device'} ({platform.system()})"
except Exception:
return "device"
def _decode_jwt_claims(token: str) -> dict:
"""Decodifica i claims di un JWT senza verificare la firma.
Ritorna {} se il token e' malformato. La verifica vera e' fatta
dal server quando rivalidiamo online; qui ci serve solo leggere
exp/email per la UI.
"""
try:
parts = token.split(".")
if len(parts) != 3:
return {}
payload = parts[1]
# base64url -> base64 standard (padding)
payload += "=" * (-len(payload) % 4)
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
data = json.loads(raw.decode("utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _http_post(path: str, body: dict) -> dict:
"""POST JSON verso LICENSE_API_URL{path}. Ritorna il JSON di risposta.
Solleva LicenseNetworkError per problemi di rete e LicenseError
per risposte HTTP 4xx/5xx (con messaggio dal server quando disponibile).
"""
url = LICENSE_API_URL.rstrip("/") + path
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers={
"Content-Type": "application/json",
"User-Agent": f"MusicTools/{VERSION}",
"Accept": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8")
err_data = json.loads(err_body)
msg = err_data.get("error") or err_data.get("message") or e.reason
except Exception:
msg = e.reason or f"HTTP {e.code}"
raise LicenseError(str(msg))
except urllib.error.URLError as e:
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
except (TimeoutError, OSError) as e:
raise LicenseNetworkError(str(e))
# ============================================================
# API pubblica
# ============================================================
def get_status(config: Optional[dict] = None) -> dict:
"""Ritorna lo stato corrente della licenza per la UI.
Chiavi: licensed (bool), reason (str), email, key, activated_at,
last_validated_at, days_since_validation, expires_soon (bool),
needs_revalidation (bool).
"""
cfg = config or load_config()
token = (cfg.get("license_token") or "").strip()
key = (cfg.get("license_key") or "").strip()
email = (cfg.get("license_email") or "").strip()
activated = int(cfg.get("license_activated_at") or 0)
last_val = int(cfg.get("last_validated_at") or 0)
if not token or not key:
return {
"licensed": False,
"reason": "not_activated",
"email": "",
"key": "",
"activated_at": 0,
"last_validated_at": 0,
"days_since_validation": 0,
"needs_revalidation": False,
}
days_since = (_now() - last_val) // 86400 if last_val else 999
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
grace_expired = days_since >= LICENSE_GRACE_DAYS
if grace_expired:
return {
"licensed": False,
"reason": "grace_expired",
"email": email,
"key": key,
"activated_at": activated,
"last_validated_at": last_val,
"days_since_validation": days_since,
"needs_revalidation": True,
}
return {
"licensed": True,
"reason": "ok",
"email": email,
"key": key,
"activated_at": activated,
"last_validated_at": last_val,
"days_since_validation": days_since,
"needs_revalidation": needs_reval,
}
def is_licensed() -> bool:
"""Helper rapido per gating delle azioni."""
return get_status()["licensed"]
def activate(license_key: str, email: str) -> dict:
"""Attiva una licenza contro il server. Salva token su success.
Ritorna dict con le chiavi di get_status(). Solleva LicenseError
o LicenseNetworkError in caso di fallimento.
"""
key = (license_key or "").strip()
mail = (email or "").strip().lower()
if not key or not mail:
raise LicenseError("Inserisci email e chiave di licenza.")
cfg = load_config()
device_id = _ensure_device_id(cfg)
resp = _http_post("/api/license/activate", {
"key": key,
"email": mail,
"device_id": device_id,
"device_name": _device_name(),
"app_version": VERSION,
})
token = (resp.get("token") or "").strip()
if not token:
raise LicenseError(resp.get("error") or "Risposta server non valida.")
now = _now()
cfg["license_key"] = key
cfg["license_email"] = mail
cfg["license_token"] = token
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
cfg["last_validated_at"] = now
save_config(cfg)
return get_status(cfg)
def validate() -> dict:
"""Rivalida il token corrente contro il server (background).
Aggiorna last_validated_at su success. Su 401 (revoca/refund)
azzera il token cosi' la prossima get_status ritorna not_activated.
Su errori di rete non fa nulla (resta valido fino al grace).
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
if not token:
return get_status(cfg)
device_id = _ensure_device_id(cfg)
try:
resp = _http_post("/api/license/validate", {
"token": token,
"device_id": device_id,
"app_version": VERSION,
})
except LicenseNetworkError:
return get_status(cfg)
except LicenseError:
# Server ha risposto 4xx -> token non piu valido
cfg["license_token"] = ""
cfg["last_validated_at"] = 0
save_config(cfg)
return get_status(cfg)
# Server puo' inviare un token rinnovato (rotazione)
new_token = (resp.get("token") or "").strip()
if new_token:
cfg["license_token"] = new_token
cfg["last_validated_at"] = _now()
save_config(cfg)
return get_status(cfg)
def deactivate(release_remote: bool = True) -> dict:
"""Disattiva la licenza su questo device.
Se release_remote, notifica il server cosi' libera lo slot
di attivazione (utile per spostare l'app su un altro device).
Sempre azzera i campi licenza locali, anche se il server e' offline.
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
device_id = (cfg.get("device_id") or "").strip()
if release_remote and token and device_id:
try:
_http_post("/api/license/deactivate", {
"token": token,
"device_id": device_id,
})
except (LicenseError, LicenseNetworkError):
# Lo facciamo local-only se il server non risponde.
pass
cfg["license_key"] = ""
cfg["license_email"] = ""
cfg["license_token"] = ""
cfg["license_activated_at"] = 0
cfg["last_validated_at"] = 0
save_config(cfg)
return get_status(cfg)
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.wrangler/
.dev.vars
*.log
dist/
+88
View File
@@ -0,0 +1,88 @@
# MusicTools License & Update API
Cloudflare Worker che gestisce attivazione licenze, validazione e distribuzione binari MusicTools.
## Stack
- **Cloudflare Workers** (compute serverless, free tier 100k req/giorno)
- **Cloudflare D1** (sqlite gestito, free tier 5GB)
- **Cloudflare R2** (storage zip binari, free tier 10GB)
- **Lemon Squeezy** (Merchant of Record per i pagamenti, gestisce IVA UE)
- **Resend** (invio email license-key, free tier 3k email/mese)
## Setup iniziale
Una volta sola, da terminale dentro `server/`:
```bash
npm install
npx wrangler login # autenticati a Cloudflare
# 1. Crea il database D1
npx wrangler d1 create musictools-licenses
# -> copia il database_id stampato nel wrangler.toml
# 2. Applica lo schema
npm run db:migrate:prod
# 3. Crea il bucket R2 per i binari
npx wrangler r2 bucket create musictools-builds
# 4. Imposta i secret (NON in chiaro nel wrangler.toml)
npx wrangler secret put JWT_SECRET # > openssl rand -base64 32
npx wrangler secret put LEMONSQUEEZY_SIGNING_SECRET # > dal dashboard LS
npx wrangler secret put RESEND_API_KEY # > dal dashboard Resend
# 5. Deploy
npm run deploy
```
## DNS
Su Cloudflare Dashboard > djluza.com > DNS aggiungi:
```
musictools CNAME <subdomain-worker>.workers.dev proxied
```
Poi vai su Workers & Pages > musictools-api > Settings > Triggers > Custom Domains
e aggiungi `musictools.djluza.com`.
## Endpoints
| Metodo | Path | Auth | Descrizione |
|---|---|---|---|
| POST | `/api/license/activate` | — | Attiva licenza, ritorna JWT |
| POST | `/api/license/validate` | — (token nel body) | Rivalida + ruota token |
| POST | `/api/license/deactivate` | — (token nel body) | Libera uno slot |
| GET | `/api/latest?platform=…` | Bearer token (opzionale) | Versione + URL download firmato |
| POST | `/api/webhook/lemonsqueezy` | X-Signature HMAC | Crea licenza dopo ordine |
| GET | `/api/health` | — | Healthcheck |
## Workflow pubblicazione release
1. GitHub Actions builda macOS e Windows zip (gia in place).
2. Step manuale (per ora): scarica i due zip, caricali su R2:
```bash
npx wrangler r2 object put musictools-builds/v1.5.3/MusicTools-macOS.zip --file=MusicTools-macOS.zip
npx wrangler r2 object put musictools-builds/v1.5.3/MusicTools-Windows.zip --file=MusicTools-Windows.zip
```
3. Inserisci il record `releases`:
```sql
INSERT INTO releases (version, platform, r2_key, size_bytes, sha256, notes, published_at)
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', strftime('%s','now'));
```
(eseguibile da `npx wrangler d1 execute musictools-licenses --remote --command "..."`)
In futuro: workflow GitHub Actions che fa upload R2 + insert D1 in automatico.
## TODO
- [ ] Implementare `/api/download` che verifica firma e fa stream da R2
- [ ] Endpoint admin per emettere licenze a mano (es. recensori, refund)
- [ ] Rate limiting con KV su `/api/license/activate` (anti brute-force)
- [ ] Cron worker giornaliero che marca le licenze inattive da > 1 anno
## Costi
A 0 vendite: **0€/mese** (tutto in free tier).
A 100 vendite/mese: ~5€ Lemon Squeezy commission + 0€ Cloudflare = ~5€.
+42
View File
@@ -0,0 +1,42 @@
-- Schema iniziale licenze MusicTools
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_key TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | revoked | refunded
source TEXT, -- es. 'lemonsqueezy', 'manual'
order_id TEXT, -- id dell'ordine LS
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_licenses_email ON licenses(email);
CREATE TABLE IF NOT EXISTS activations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
device_name TEXT,
app_version TEXT,
activated_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
revoked_at INTEGER,
UNIQUE (license_id, device_id)
);
CREATE INDEX IF NOT EXISTS idx_activations_license ON activations(license_id);
CREATE TABLE IF NOT EXISTS releases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL,
platform TEXT NOT NULL, -- macos | windows
r2_key TEXT NOT NULL, -- chiave dentro il bucket R2
size_bytes INTEGER,
sha256 TEXT,
notes TEXT,
published_at INTEGER NOT NULL,
UNIQUE (version, platform)
);
CREATE INDEX IF NOT EXISTS idx_releases_platform_pub ON releases(platform, published_at DESC);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "musictools-license-server",
"version": "0.1.0",
"private": true,
"description": "License + update API for MusicTools (Cloudflare Workers + D1)",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:create": "wrangler d1 create musictools-licenses",
"db:migrate:local": "wrangler d1 migrations apply musictools-licenses --local",
"db:migrate:prod": "wrangler d1 migrations apply musictools-licenses --remote",
"tail": "wrangler tail"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251101.0",
"typescript": "^5.6.0",
"wrangler": "^3.95.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
export function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
...headers,
},
});
}
export function notFound(): Response {
return json({ error: "Not found" }, 404);
}
export function methodNotAllowed(): Response {
return json({ error: "Method not allowed" }, 405);
}
export function badRequest(msg: string): Response {
return json({ error: msg }, 400);
}
export function unauthorized(msg = "Unauthorized"): Response {
return json({ error: msg }, 401);
}
export async function readJson<T = any>(req: Request): Promise<T> {
try {
return (await req.json()) as T;
} catch {
throw new Response(JSON.stringify({ error: "Invalid JSON" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
export function now(): number {
return Math.floor(Date.now() / 1000);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Minimal JWT HS256 implementation using Web Crypto (available in Workers).
* We don't pull in a library to keep the worker bundle tiny.
*/
function b64url(buf: ArrayBuffer | Uint8Array): string {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function b64urlDecode(s: string): Uint8Array {
s = s.replace(/-/g, "+").replace(/_/g, "/");
s += "=".repeat((4 - (s.length % 4)) % 4);
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hmac(secret: string, data: string): Promise<ArrayBuffer> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
return crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
}
export interface JwtClaims {
sub: string; // license_id
key_id: string; // license_key (mascherata o intera)
email: string;
device_id: string;
iat: number;
exp: number;
[k: string]: unknown;
}
export async function signJwt(claims: JwtClaims, secret: string): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const head = b64url(new TextEncoder().encode(JSON.stringify(header)));
const body = b64url(new TextEncoder().encode(JSON.stringify(claims)));
const sig = b64url(await hmac(secret, `${head}.${body}`));
return `${head}.${body}.${sig}`;
}
export async function verifyJwt(token: string, secret: string): Promise<JwtClaims | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [head, body, sig] = parts;
const expected = b64url(await hmac(secret, `${head}.${body}`));
if (expected !== sig) return null;
try {
const claims = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as JwtClaims;
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
return null;
}
return claims;
} catch {
return null;
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* Webhook Lemon Squeezy.
*
* Configurazione:
* - Crea il webhook dal dashboard LS (My Store > Settings > Webhooks)
* - URL: https://musictools.djluza.com/api/webhook/lemonsqueezy
* - Eventi: order_created, subscription_payment_success (per future estensioni),
* order_refunded
* - Secret: salvalo come "LEMONSQUEEZY_SIGNING_SECRET" (wrangler secret put)
*
* Flusso order_created:
* 1. Verifica firma X-Signature == HMAC-SHA256(secret, raw_body)
* 2. Estrai email cliente + order_id
* 3. Genera license_key (XXXX-XXXX-XXXX-XXXX), insert in 'licenses'
* 4. Invia email all'utente via Resend con la chiave
*/
import { json, now } from "./http";
import type { Env } from "./worker";
interface LSPayload {
meta?: { event_name?: string; custom_data?: Record<string, unknown> };
data?: {
id?: string;
type?: string;
attributes?: {
user_email?: string;
order_number?: number | string;
refunded?: boolean;
status?: string;
};
};
}
export async function handleLemonSqueezyWebhook(req: Request, env: Env): Promise<Response> {
const raw = await req.text();
const sig = req.headers.get("X-Signature") || "";
if (!sig || !env.LEMONSQUEEZY_SIGNING_SECRET) {
return json({ error: "Missing signature" }, 400);
}
const expected = await hmacHex(env.LEMONSQUEEZY_SIGNING_SECRET, raw);
if (!timingSafeEqual(sig, expected)) {
return json({ error: "Invalid signature" }, 401);
}
let payload: LSPayload;
try {
payload = JSON.parse(raw) as LSPayload;
} catch {
return json({ error: "Invalid JSON" }, 400);
}
const eventName = payload.meta?.event_name || "";
const attrs = payload.data?.attributes || {};
const email = (attrs.user_email || "").trim().toLowerCase();
const orderId = String(payload.data?.id || attrs.order_number || "");
if (!email || !orderId) {
return json({ error: "Missing email or order_id" }, 400);
}
const t = now();
if (eventName === "order_created") {
const key = generateLicenseKey();
try {
await env.DB.prepare(
`INSERT INTO licenses (license_key, email, status, source, order_id, created_at, updated_at)
VALUES (?1, ?2, 'active', 'lemonsqueezy', ?3, ?4, ?4)`
).bind(key, email, orderId, t).run();
} catch (e) {
// unique violation (webhook duplicato): no-op
console.warn("Insert license failed (probabile duplicato):", e);
return json({ ok: true, duplicate: true });
}
await sendLicenseEmail(env, email, key);
return json({ ok: true, license_key_masked: key.slice(0, 4) + "..." });
}
if (eventName === "order_refunded") {
await env.DB.prepare(
`UPDATE licenses SET status='refunded', updated_at=?1
WHERE order_id=?2`
).bind(t, orderId).run();
return json({ ok: true });
}
return json({ ok: true, ignored: eventName });
}
function generateLicenseKey(): string {
// 16 caratteri base32 (no I/O/0/1 ambigui), in 4 gruppi da 4.
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12), chars.slice(12, 16)]
.map((g) => g.join("")).join("-");
}
async function hmacHex(secret: string, data: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
async function sendLicenseEmail(env: Env, email: string, key: string): Promise<void> {
if (!env.RESEND_API_KEY) {
console.warn("RESEND_API_KEY non impostata, skip invio email");
return;
}
const body = {
from: "MusicTools <noreply@djluza.com>",
to: [email],
subject: "La tua licenza MusicTools",
html: `
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
<h1 style="color:#1db954">Grazie per aver scelto MusicTools!</h1>
<p>Ecco la tua chiave di licenza:</p>
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:14px;border-radius:8px;text-align:center">
${key}
</p>
<p>Per attivarla:</p>
<ol>
<li>Scarica MusicTools per <a href="https://musictools.djluza.com/download/macos">macOS</a> o <a href="https://musictools.djluza.com/download/windows">Windows</a></li>
<li>Apri l'app: ti chiedera' email e chiave</li>
<li>Inserisci questa email (<code>${email}</code>) e la chiave qui sopra</li>
</ol>
<p>Puoi attivare la licenza fino a 3 dispositivi.</p>
<hr/>
<p style="color:#666;font-size:12px">Hai problemi? Scrivici a info@djluza.com</p>
</div>
`,
};
const resp = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!resp.ok) {
console.error("Resend error:", await resp.text());
}
}
+179
View File
@@ -0,0 +1,179 @@
import { json, badRequest, unauthorized, readJson, now } from "./http";
import { signJwt, verifyJwt } from "./jwt";
import type { Env } from "./worker";
interface LicenseRow {
id: number;
license_key: string;
email: string;
status: string;
}
interface ActivationRow {
id: number;
license_id: number;
device_id: string;
device_name: string | null;
app_version: string | null;
activated_at: number;
last_seen_at: number;
revoked_at: number | null;
}
function normalizeEmail(s: string): string {
return (s || "").trim().toLowerCase();
}
function normalizeKey(s: string): string {
return (s || "").trim().toUpperCase();
}
async function getLicense(env: Env, key: string, email: string): Promise<LicenseRow | null> {
const stmt = env.DB.prepare(
`SELECT id, license_key, email, status FROM licenses
WHERE license_key = ?1 AND email = ?2 LIMIT 1`
).bind(key, email);
return await stmt.first<LicenseRow>();
}
async function countActiveActivations(env: Env, licenseId: number): Promise<number> {
const row = await env.DB.prepare(
`SELECT COUNT(*) AS n FROM activations
WHERE license_id = ?1 AND revoked_at IS NULL`
).bind(licenseId).first<{ n: number }>();
return row?.n ?? 0;
}
async function findActivation(env: Env, licenseId: number, deviceId: string): Promise<ActivationRow | null> {
return await env.DB.prepare(
`SELECT * FROM activations
WHERE license_id = ?1 AND device_id = ?2 LIMIT 1`
).bind(licenseId, deviceId).first<ActivationRow>();
}
async function issueToken(env: Env, license: LicenseRow, deviceId: string): Promise<string> {
const ttlDays = parseInt(env.TOKEN_TTL_DAYS || "30", 10);
const t = now();
return signJwt({
sub: String(license.id),
key_id: license.license_key,
email: license.email,
device_id: deviceId,
iat: t,
exp: t + ttlDays * 86400,
}, env.JWT_SECRET);
}
// ============================================================
// POST /api/license/activate
// ============================================================
export async function handleActivate(req: Request, env: Env): Promise<Response> {
const body = await readJson<{
key?: string; email?: string; device_id?: string;
device_name?: string; app_version?: string;
}>(req);
const key = normalizeKey(body.key || "");
const email = normalizeEmail(body.email || "");
const deviceId = (body.device_id || "").trim();
if (!key || !email || !deviceId) {
return badRequest("Missing key, email or device_id");
}
const license = await getLicense(env, key, email);
if (!license) {
return json({ error: "Chiave o email non corrispondono a un acquisto." }, 404);
}
if (license.status !== "active") {
return json({ error: "Licenza non piu' valida (rimborsata o revocata)." }, 403);
}
const max = parseInt(env.MAX_ACTIVATIONS || "3", 10);
const t = now();
const existing = await findActivation(env, license.id, deviceId);
if (existing) {
// Re-attivazione sullo stesso device: aggiorna last_seen.
await env.DB.prepare(
`UPDATE activations
SET app_version=?1, device_name=?2, last_seen_at=?3, revoked_at=NULL
WHERE id=?4`
).bind(body.app_version || null, body.device_name || null, t, existing.id).run();
} else {
const active = await countActiveActivations(env, license.id);
if (active >= max) {
return json({
error: `Hai gia attivato la licenza su ${max} dispositivi. Disattivane uno per usarla qui.`,
}, 409);
}
await env.DB.prepare(
`INSERT INTO activations
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
).bind(license.id, deviceId, body.device_name || null, body.app_version || null, t).run();
}
const token = await issueToken(env, license, deviceId);
return json({
token,
activated_at: t,
email: license.email,
});
}
// ============================================================
// POST /api/license/validate
// ============================================================
export async function handleValidate(req: Request, env: Env): Promise<Response> {
const body = await readJson<{ token?: string; device_id?: string; app_version?: string }>(req);
const token = (body.token || "").trim();
const deviceId = (body.device_id || "").trim();
if (!token || !deviceId) return badRequest("Missing token or device_id");
const claims = await verifyJwt(token, env.JWT_SECRET);
if (!claims) return unauthorized("Token invalido o scaduto");
if (claims.device_id !== deviceId) {
return unauthorized("device_id mismatch");
}
const license = await env.DB.prepare(
`SELECT id, license_key, email, status FROM licenses WHERE id = ?1`
).bind(claims.sub).first<LicenseRow>();
if (!license || license.status !== "active") {
return unauthorized("Licenza non attiva");
}
const act = await findActivation(env, license.id, deviceId);
if (!act || act.revoked_at !== null) {
return unauthorized("Attivazione non trovata o revocata");
}
await env.DB.prepare(
`UPDATE activations SET last_seen_at=?1, app_version=?2 WHERE id=?3`
).bind(now(), body.app_version || act.app_version, act.id).run();
// Rotazione token: ne emettiamo uno nuovo per estendere l'exp
const fresh = await issueToken(env, license, deviceId);
return json({ token: fresh, email: license.email });
}
// ============================================================
// POST /api/license/deactivate
// ============================================================
export async function handleDeactivate(req: Request, env: Env): Promise<Response> {
const body = await readJson<{ token?: string; device_id?: string }>(req);
const token = (body.token || "").trim();
const deviceId = (body.device_id || "").trim();
if (!token || !deviceId) return badRequest("Missing token or device_id");
const claims = await verifyJwt(token, env.JWT_SECRET);
if (!claims) return unauthorized("Token invalido");
if (claims.device_id !== deviceId) return unauthorized("device_id mismatch");
await env.DB.prepare(
`UPDATE activations SET revoked_at=?1
WHERE license_id=?2 AND device_id=?3 AND revoked_at IS NULL`
).bind(now(), claims.sub, deviceId).run();
return json({ ok: true });
}
+94
View File
@@ -0,0 +1,94 @@
import { json, badRequest, unauthorized } from "./http";
import { verifyJwt } from "./jwt";
import type { Env } from "./worker";
interface ReleaseRow {
version: string;
platform: string;
r2_key: string;
size_bytes: number | null;
sha256: string | null;
notes: string | null;
published_at: number;
}
/**
* GET /api/latest?platform=macos|windows&current=v1.5.2
* Authorization: Bearer <token> (opzionale ma necessario per ricevere download_url)
*
* Risposta:
* {
* version, notes, sha256,
* download_url (firmato, scade in DOWNLOAD_URL_TTL_SECONDS) -- solo se token valido
* }
*/
export async function handleLatest(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const platform = (url.searchParams.get("platform") || "").toLowerCase();
if (platform !== "macos" && platform !== "windows") {
return badRequest("platform must be macos or windows");
}
const row = await env.DB.prepare(
`SELECT version, platform, r2_key, size_bytes, sha256, notes, published_at
FROM releases
WHERE platform = ?1
ORDER BY published_at DESC
LIMIT 1`
).bind(platform).first<ReleaseRow>();
if (!row) {
return json({
version: env.LATEST_VERSION || "",
notes: "",
download_url: "",
requires_license: true,
});
}
// Auth opzionale: senza token rispondiamo solo con metadata (version + notes).
const auth = req.headers.get("Authorization") || "";
let licensed = false;
if (auth.startsWith("Bearer ")) {
const token = auth.slice(7).trim();
const claims = await verifyJwt(token, env.JWT_SECRET);
licensed = !!claims;
}
let downloadUrl = "";
if (licensed) {
// R2 non genera URL firmati nativi via Workers SDK in modo semplice.
// Soluzione: serviamo il file via questo Worker su un path firmato HMAC
// con scadenza. /api/download?key=<r2_key>&exp=<ts>&sig=<hmac>
downloadUrl = await signDownloadUrl(env, row.r2_key);
}
return json({
version: row.version,
notes: row.notes || "",
sha256: row.sha256 || "",
size_bytes: row.size_bytes || 0,
download_url: downloadUrl,
requires_license: !licensed,
});
}
async function signDownloadUrl(env: Env, r2Key: string): Promise<string> {
// Implementazione minima: torniamo un URL relativo che un altro endpoint
// /api/download verifichera prima di servire il file da R2.
// Per ora restituisco un placeholder; vai a implementare /api/download
// in un secondo passaggio se vuoi servire i binari dietro firma.
const ttl = parseInt(env.DOWNLOAD_URL_TTL_SECONDS || "300", 10);
const exp = Math.floor(Date.now() / 1000) + ttl;
const payload = `${r2Key}.${exp}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(env.JWT_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false, ["sign"],
);
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf)))
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
return `https://musictools.djluza.com/api/download?key=${encodeURIComponent(r2Key)}&exp=${exp}&sig=${sig}`;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* MusicTools License & Update API
*
* Endpoints:
* POST /api/license/activate body: { key, email, device_id, device_name, app_version }
* POST /api/license/validate body: { token, device_id, app_version }
* POST /api/license/deactivate body: { token, device_id }
* GET /api/latest?platform=macos|windows&current=v1.5.2 [Authorization: Bearer <token>]
* POST /api/webhook/lemonsqueezy (firmato HMAC, crea licenza dopo ordine)
*
* Auth model:
* - L'app non ha account: la "verita" e' (license_key, email).
* - Dopo activate(), il server emette un JWT HMAC con claims
* { sub: license_id, key_id, email, device_id, iat, exp }.
* Il client lo salva e lo manda a ogni revalidate / /api/latest.
* - revoke = update licenses.status='revoked' + tutti i validate falliscono.
*/
import { handleActivate, handleValidate, handleDeactivate } from "./license";
import { handleLatest } from "./updates";
import { handleLemonSqueezyWebhook } from "./lemonsqueezy";
import { json, methodNotAllowed, notFound } from "./http";
export interface Env {
DB: D1Database;
BUILDS: R2Bucket;
JWT_SECRET: string;
LEMONSQUEEZY_SIGNING_SECRET: string;
RESEND_API_KEY: string;
LATEST_VERSION: string;
MAX_ACTIVATIONS: string;
TOKEN_TTL_DAYS: string;
DOWNLOAD_URL_TTL_SECONDS: string;
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
const method = req.method.toUpperCase();
// CORS (utile se in futuro vuoi chiamare l'API dalla landing page)
if (method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type,Authorization",
"Access-Control-Max-Age": "86400",
},
});
}
try {
if (path === "/api/license/activate") {
if (method !== "POST") return methodNotAllowed();
return await handleActivate(req, env);
}
if (path === "/api/license/validate") {
if (method !== "POST") return methodNotAllowed();
return await handleValidate(req, env);
}
if (path === "/api/license/deactivate") {
if (method !== "POST") return methodNotAllowed();
return await handleDeactivate(req, env);
}
if (path === "/api/latest") {
if (method !== "GET") return methodNotAllowed();
return await handleLatest(req, env);
}
if (path === "/api/webhook/lemonsqueezy") {
if (method !== "POST") return methodNotAllowed();
return await handleLemonSqueezyWebhook(req, env);
}
if (path === "/api/health") {
return json({ ok: true, version: env.LATEST_VERSION });
}
return notFound();
} catch (err) {
console.error("Unhandled error:", err);
return json({ error: "Internal error" }, 500);
}
},
};
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
+42
View File
@@ -0,0 +1,42 @@
name = "musictools-api"
main = "src/worker.ts"
compatibility_date = "2026-01-01"
# Routes: musictools.djluza.com/api/* va a questo worker.
# Configurare nel dashboard Cloudflare DNS + Workers Routes
# oppure decommentare se zona gia mappata:
# routes = [
# { pattern = "musictools.djluza.com/api/*", zone_name = "djluza.com" }
# ]
# D1 database (sqlite gestito da Cloudflare).
# Crealo una volta con: npm run db:create
# Poi sostituisci database_id qui sotto con quello restituito.
[[d1_databases]]
binding = "DB"
database_name = "musictools-licenses"
database_id = "REPLACE_AFTER_db:create"
# R2 bucket dove conservi gli zip macOS/Windows.
# Cli: wrangler r2 bucket create musictools-builds
[[r2_buckets]]
binding = "BUILDS"
bucket_name = "musictools-builds"
# KV per rate-limiting (opzionale ma consigliato).
# Cli: wrangler kv:namespace create RATELIMIT
# [[kv_namespaces]]
# binding = "RATELIMIT"
# id = "REPLACE_ME"
# Variabili NON segrete.
[vars]
LATEST_VERSION = "v1.5.2"
MAX_ACTIVATIONS = "3"
TOKEN_TTL_DAYS = "30"
DOWNLOAD_URL_TTL_SECONDS = "300"
# Secrets (impostarli da CLI, NON in chiaro qui):
# wrangler secret put JWT_SECRET # HMAC key per i token offline
# wrangler secret put LEMONSQUEEZY_SIGNING_SECRET # verifica webhook
# wrangler secret put RESEND_API_KEY # invio email license-key
+154
View File
@@ -1256,3 +1256,157 @@ input[type="number"]::-webkit-inner-spin-button {
max-height: none;
}
}
/* ============================================================
SCHERMATA ATTIVAZIONE LICENZA
============================================================ */
.activate-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
background:
radial-gradient(ellipse at top, rgba(29, 185, 84, 0.18), transparent 60%),
linear-gradient(180deg, #0a0a0a 0%, #060606 100%);
overflow: auto;
}
.activate-screen[hidden] { display: none; }
.activate-card {
width: 100%;
max-width: 460px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 40px 36px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
text-align: center;
}
.activate-logo {
width: 72px;
height: 72px;
margin: 0 auto 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
background: linear-gradient(135deg, #1db954 0%, #137a37 100%);
color: #fff;
font-size: 36px;
box-shadow: 0 12px 30px rgba(29, 185, 84, 0.4);
}
.activate-title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 800;
color: var(--text);
}
.activate-sub {
margin: 0 0 28px;
font-size: 13.5px;
color: var(--text-muted);
line-height: 1.55;
}
.activate-form {
text-align: left;
}
.activate-form .field {
margin-bottom: 14px;
}
.activate-error {
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.4);
color: #ff6b7a;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
margin: 6px 0 14px;
}
.activate-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.activate-actions .btn {
width: 100%;
justify-content: center;
}
.activate-foot {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.activate-foot a {
color: var(--accent);
text-decoration: none;
}
.activate-foot a:hover { text-decoration: underline; }
/* ============================================================
IMPOSTAZIONI > LICENZA
============================================================ */
.license-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.lic-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px dashed var(--divider);
font-size: 13.5px;
}
.lic-row:last-child { border-bottom: none; }
.lic-row span {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.6px;
font-size: 11px;
font-weight: 600;
}
.lic-row strong {
color: var(--text);
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
}
.lic-warn {
padding: 14px;
border-radius: 12px;
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.35);
color: #ff6b7a;
font-weight: 700;
text-align: center;
}
.license-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
+54
View File
@@ -520,6 +520,22 @@
</div>
</div>
<h2 class="section-label">Licenza</h2>
<div class="card">
<div class="license-box" id="licenseStatusBox"></div>
<div class="license-actions">
<button class="btn btn-ghost pill" id="revalidateLicenseBtn">Verifica ora</button>
<button class="btn btn-danger pill" id="deactivateLicenseBtn">Disattiva su questo device</button>
</div>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Disattivando liberi uno slot di attivazione, utile se sposti l'app su un altro Mac/PC.
Puoi riattivare in qualsiasi momento con la stessa email e chiave.
</div>
</div>
</div>
<h2 class="section-label">Aggiornamenti</h2>
<div class="card">
<div class="row spread">
@@ -813,6 +829,44 @@
</main>
</div>
<!-- ============== SCHERMATA ATTIVAZIONE LICENZA ============== -->
<div class="activate-screen" id="activateScreen" hidden>
<div class="activate-card">
<div class="activate-logo">♫</div>
<h1 class="activate-title">Benvenuto in MusicTools</h1>
<p class="activate-sub">
Inserisci la tua licenza per iniziare. Se non ne hai ancora una,
acquistala dal nostro sito ed entra in pochi secondi.
</p>
<div class="activate-form">
<div class="field">
<label class="field-label">Email dell'acquisto</label>
<input type="email" id="actEmail" class="input pill" placeholder="tu@esempio.com" autocomplete="email" />
</div>
<div class="field">
<label class="field-label">Chiave di licenza</label>
<input type="text" id="actKey" class="input pill" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" />
</div>
<div class="activate-error" id="actError" hidden></div>
<div class="activate-actions">
<button class="btn btn-primary pill btn-lg" id="actActivateBtn">
<span class="ico">🔓</span> Attiva
</button>
<button class="btn btn-ghost pill btn-lg" id="actBuyBtn">
Compra una licenza
</button>
</div>
</div>
<div class="activate-foot">
Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>
</div>
</div>
</div>
<!-- ============== MODAL: Spotify Guide ============== -->
<div class="modal-backdrop" id="guideModal" hidden>
<div class="modal">
+127
View File
@@ -7,6 +7,8 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = {
config: {},
license: { licensed: false },
purchaseUrl: "https://musictools.djluza.com",
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
dlOutputDir: "",
upDir: "",
@@ -45,6 +47,90 @@ function parseQueryToTrack(q) {
return { artist: "", name: s };
}
// ============================================================
// Licenza: gating della UI principale
// ============================================================
function applyLicenseGate() {
const lic = state.license || { licensed: false };
const screen = $("#activateScreen");
const appEl = document.querySelector(".app");
if (!lic.licensed) {
if (screen) screen.hidden = false;
if (appEl) appEl.style.display = "none";
} else {
if (screen) screen.hidden = true;
if (appEl) appEl.style.display = "";
}
}
function fmtDate(epoch) {
if (!epoch) return "—";
try {
return new Date(epoch * 1000).toLocaleDateString("it-IT", {
day: "2-digit", month: "short", year: "numeric"
});
} catch (_e) { return "—"; }
}
function renderLicenseStatus() {
const lic = state.license || {};
const box = $("#licenseStatusBox");
if (!box) return; // panel non ancora montato
if (!lic.licensed) {
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
return;
}
box.innerHTML = `
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
<div class="lic-row"><span>Ultima verifica</span><strong>${fmtDate(lic.last_validated_at)}</strong></div>
`;
}
async function activateLicense() {
const email = $("#actEmail").value.trim();
const key = $("#actKey").value.trim();
const err = $("#actError");
err.hidden = true;
err.textContent = "";
if (!email || !key) {
err.textContent = "Inserisci email e chiave di licenza.";
err.hidden = false;
return;
}
const btn = $("#actActivateBtn");
btn.disabled = true;
const oldText = btn.innerHTML;
btn.innerHTML = "Attivazione in corso…";
try {
const res = await window.pywebview.api.activate_license({ email, key });
if (res.ok) {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
toast("Licenza attivata. Benvenuto!", "success");
} else {
err.textContent = res.error || "Errore di attivazione.";
err.hidden = false;
}
} finally {
btn.disabled = false;
btn.innerHTML = oldText;
}
}
async function deactivateLicense() {
if (!confirm("Disattivare la licenza su questo dispositivo?")) return;
const res = await window.pywebview.api.deactivate_license();
if (res.ok) {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
toast("Licenza disattivata", "info");
}
}
// ============================================================
// Bridge — eventi spinti dal backend
// ============================================================
@@ -234,10 +320,15 @@ async function init() {
await waitApi();
const data = await window.pywebview.api.get_init_data();
state.config = data.config || {};
state.license = data.license || { licensed: false };
state.purchaseUrl = data.purchase_url || "https://musictools.djluza.com";
$("#footerVersion").textContent = data.version;
$("#currentVersionLabel").textContent = data.version;
$("#guideBody").textContent = data.spotify_guide;
applyLicenseGate();
renderLicenseStatus();
// Populate Settings fields
$("#clientIdInput").value = state.config.client_id || "";
$("#clientSecretInput").value = state.config.client_secret || "";
@@ -855,6 +946,42 @@ $("#checkUpdateBtn").addEventListener("click", async () => {
}
});
// ============================================================
// Licenza: listener dello schermo di attivazione + impostazioni
// ============================================================
$("#actActivateBtn")?.addEventListener("click", activateLicense);
$("#actBuyBtn")?.addEventListener("click", async () => {
await window.pywebview.api.open_purchase_page();
});
$("#actKey")?.addEventListener("keydown", (e) => {
if (e.key === "Enter") activateLicense();
});
$("#deactivateLicenseBtn")?.addEventListener("click", deactivateLicense);
$("#revalidateLicenseBtn")?.addEventListener("click", async () => {
const btn = $("#revalidateLicenseBtn");
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "Verifica in corso…";
try {
const res = await window.pywebview.api.revalidate_license();
if (res.ok) {
state.license = res.license;
renderLicenseStatus();
if (res.license.licensed) {
toast("Licenza verificata", "success");
} else {
applyLicenseGate();
toast("Licenza non piu valida", "error");
}
}
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
// ============================================================
// Boot
// ============================================================