From 5202b2c5869d3c35c0b745e7f800694d23c5f45d Mon Sep 17 00:00:00 2001 From: luzadev Date: Mon, 8 Jun 2026 23:36:41 +0200 Subject: [PATCH] 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 --- .gitignore | 6 + api/bridge.py | 109 ++++++++---- core/config.py | 18 ++ core/license.py | 302 ++++++++++++++++++++++++++++++++ server/.gitignore | 5 + server/README.md | 88 ++++++++++ server/migrations/0001_init.sql | 42 +++++ server/package.json | 19 ++ server/src/http.ts | 41 +++++ server/src/jwt.ts | 66 +++++++ server/src/lemonsqueezy.ts | 157 +++++++++++++++++ server/src/license.ts | 179 +++++++++++++++++++ server/src/updates.ts | 94 ++++++++++ server/src/worker.ts | 85 +++++++++ server/tsconfig.json | 15 ++ server/wrangler.toml | 42 +++++ webui/css/style.css | 154 ++++++++++++++++ webui/index.html | 54 ++++++ webui/js/app.js | 127 ++++++++++++++ 19 files changed, 1572 insertions(+), 31 deletions(-) create mode 100644 core/license.py create mode 100644 server/.gitignore create mode 100644 server/README.md create mode 100644 server/migrations/0001_init.sql create mode 100644 server/package.json create mode 100644 server/src/http.ts create mode 100644 server/src/jwt.ts create mode 100644 server/src/lemonsqueezy.ts create mode 100644 server/src/license.ts create mode 100644 server/src/updates.ts create mode 100644 server/src/worker.ts create mode 100644 server/tsconfig.json create mode 100644 server/wrangler.toml diff --git a/.gitignore b/.gitignore index 3d0b6b4..fadc4fb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/api/bridge.py b/api/bridge.py index a910088..1e71fb4 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -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 [] - 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 + 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" + plat = "macos" if is_macos else "windows" + + url = f"{LICENSE_API_URL.rstrip('/')}/api/latest?platform={plat}¤t={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} diff --git a/core/config.py b/core/config.py index cc8c07b..59dade9 100644 --- a/core/config.py +++ b/core/config.py @@ -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 } diff --git a/core/license.py b/core/license.py new file mode 100644 index 0000000..33372c8 --- /dev/null +++ b/core/license.py @@ -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) diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..e369fd2 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.wrangler/ +.dev.vars +*.log +dist/ diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..667507f --- /dev/null +++ b/server/README.md @@ -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 .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, '', '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€. diff --git a/server/migrations/0001_init.sql b/server/migrations/0001_init.sql new file mode 100644 index 0000000..6fe362e --- /dev/null +++ b/server/migrations/0001_init.sql @@ -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); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5901b9e --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/src/http.ts b/server/src/http.ts new file mode 100644 index 0000000..9f51fc7 --- /dev/null +++ b/server/src/http.ts @@ -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(req: Request): Promise { + try { + return (await req.json()) as T; + } catch { + throw new Response(JSON.stringify({ error: "Invalid JSON" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } +} + +export function now(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/server/src/jwt.ts b/server/src/jwt.ts new file mode 100644 index 0000000..a9c64a5 --- /dev/null +++ b/server/src/jwt.ts @@ -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 { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + return crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)); +} + +export interface JwtClaims { + sub: string; // license_id + key_id: string; // license_key (mascherata o intera) + email: string; + device_id: string; + iat: number; + exp: number; + [k: string]: unknown; +} + +export async function signJwt(claims: JwtClaims, secret: string): Promise { + const header = { alg: "HS256", typ: "JWT" }; + const head = b64url(new TextEncoder().encode(JSON.stringify(header))); + const body = b64url(new TextEncoder().encode(JSON.stringify(claims))); + const sig = b64url(await hmac(secret, `${head}.${body}`)); + return `${head}.${body}.${sig}`; +} + +export async function verifyJwt(token: string, secret: string): Promise { + const parts = token.split("."); + if (parts.length !== 3) return null; + const [head, body, sig] = parts; + const expected = b64url(await hmac(secret, `${head}.${body}`)); + if (expected !== sig) return null; + try { + const claims = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as JwtClaims; + if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) { + return null; + } + return claims; + } catch { + return null; + } +} diff --git a/server/src/lemonsqueezy.ts b/server/src/lemonsqueezy.ts new file mode 100644 index 0000000..c9fbd9f --- /dev/null +++ b/server/src/lemonsqueezy.ts @@ -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 }; + data?: { + id?: string; + type?: string; + attributes?: { + user_email?: string; + order_number?: number | string; + refunded?: boolean; + status?: string; + }; + }; +} + +export async function handleLemonSqueezyWebhook(req: Request, env: Env): Promise { + const raw = await req.text(); + const sig = req.headers.get("X-Signature") || ""; + if (!sig || !env.LEMONSQUEEZY_SIGNING_SECRET) { + return json({ error: "Missing signature" }, 400); + } + + const expected = await hmacHex(env.LEMONSQUEEZY_SIGNING_SECRET, raw); + if (!timingSafeEqual(sig, expected)) { + return json({ error: "Invalid signature" }, 401); + } + + let payload: LSPayload; + try { + payload = JSON.parse(raw) as LSPayload; + } catch { + return json({ error: "Invalid JSON" }, 400); + } + + const eventName = payload.meta?.event_name || ""; + const attrs = payload.data?.attributes || {}; + const email = (attrs.user_email || "").trim().toLowerCase(); + const orderId = String(payload.data?.id || attrs.order_number || ""); + + if (!email || !orderId) { + return json({ error: "Missing email or order_id" }, 400); + } + + const t = now(); + + if (eventName === "order_created") { + const key = generateLicenseKey(); + try { + await env.DB.prepare( + `INSERT INTO licenses (license_key, email, status, source, order_id, created_at, updated_at) + VALUES (?1, ?2, 'active', 'lemonsqueezy', ?3, ?4, ?4)` + ).bind(key, email, orderId, t).run(); + } catch (e) { + // unique violation (webhook duplicato): no-op + console.warn("Insert license failed (probabile duplicato):", e); + return json({ ok: true, duplicate: true }); + } + await sendLicenseEmail(env, email, key); + return json({ ok: true, license_key_masked: key.slice(0, 4) + "..." }); + } + + if (eventName === "order_refunded") { + await env.DB.prepare( + `UPDATE licenses SET status='refunded', updated_at=?1 + WHERE order_id=?2` + ).bind(t, orderId).run(); + return json({ ok: true }); + } + + return json({ ok: true, ignored: eventName }); +} + +function generateLicenseKey(): string { + // 16 caratteri base32 (no I/O/0/1 ambigui), in 4 gruppi da 4. + const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]); + return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12), chars.slice(12, 16)] + .map((g) => g.join("")).join("-"); +} + +async function hmacHex(secret: string, data: string): Promise { + const key = await crypto.subtle.importKey( + "raw", new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, false, ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data)); + return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join(""); +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +async function sendLicenseEmail(env: Env, email: string, key: string): Promise { + if (!env.RESEND_API_KEY) { + console.warn("RESEND_API_KEY non impostata, skip invio email"); + return; + } + const body = { + from: "MusicTools ", + to: [email], + subject: "La tua licenza MusicTools", + html: ` +
+

Grazie per aver scelto MusicTools!

+

Ecco la tua chiave di licenza:

+

+ ${key} +

+

Per attivarla:

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

Puoi attivare la licenza fino a 3 dispositivi.

+
+

Hai problemi? Scrivici a info@djluza.com

+
+ `, + }; + const resp = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Authorization": `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + console.error("Resend error:", await resp.text()); + } +} diff --git a/server/src/license.ts b/server/src/license.ts new file mode 100644 index 0000000..51b3924 --- /dev/null +++ b/server/src/license.ts @@ -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 { + const stmt = env.DB.prepare( + `SELECT id, license_key, email, status FROM licenses + WHERE license_key = ?1 AND email = ?2 LIMIT 1` + ).bind(key, email); + return await stmt.first(); +} + +async function countActiveActivations(env: Env, licenseId: number): Promise { + const row = await env.DB.prepare( + `SELECT COUNT(*) AS n FROM activations + WHERE license_id = ?1 AND revoked_at IS NULL` + ).bind(licenseId).first<{ n: number }>(); + return row?.n ?? 0; +} + +async function findActivation(env: Env, licenseId: number, deviceId: string): Promise { + return await env.DB.prepare( + `SELECT * FROM activations + WHERE license_id = ?1 AND device_id = ?2 LIMIT 1` + ).bind(licenseId, deviceId).first(); +} + +async function issueToken(env: Env, license: LicenseRow, deviceId: string): Promise { + const ttlDays = parseInt(env.TOKEN_TTL_DAYS || "30", 10); + const t = now(); + return signJwt({ + sub: String(license.id), + key_id: license.license_key, + email: license.email, + device_id: deviceId, + iat: t, + exp: t + ttlDays * 86400, + }, env.JWT_SECRET); +} + +// ============================================================ +// POST /api/license/activate +// ============================================================ +export async function handleActivate(req: Request, env: Env): Promise { + const body = await readJson<{ + key?: string; email?: string; device_id?: string; + device_name?: string; app_version?: string; + }>(req); + + const key = normalizeKey(body.key || ""); + const email = normalizeEmail(body.email || ""); + const deviceId = (body.device_id || "").trim(); + if (!key || !email || !deviceId) { + return badRequest("Missing key, email or device_id"); + } + + const license = await getLicense(env, key, email); + if (!license) { + return json({ error: "Chiave o email non corrispondono a un acquisto." }, 404); + } + if (license.status !== "active") { + return json({ error: "Licenza non piu' valida (rimborsata o revocata)." }, 403); + } + + const max = parseInt(env.MAX_ACTIVATIONS || "3", 10); + const t = now(); + + const existing = await findActivation(env, license.id, deviceId); + if (existing) { + // Re-attivazione sullo stesso device: aggiorna last_seen. + await env.DB.prepare( + `UPDATE activations + SET app_version=?1, device_name=?2, last_seen_at=?3, revoked_at=NULL + WHERE id=?4` + ).bind(body.app_version || null, body.device_name || null, t, existing.id).run(); + } else { + const active = await countActiveActivations(env, license.id); + if (active >= max) { + return json({ + error: `Hai gia attivato la licenza su ${max} dispositivi. Disattivane uno per usarla qui.`, + }, 409); + } + await env.DB.prepare( + `INSERT INTO activations + (license_id, device_id, device_name, app_version, activated_at, last_seen_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)` + ).bind(license.id, deviceId, body.device_name || null, body.app_version || null, t).run(); + } + + const token = await issueToken(env, license, deviceId); + return json({ + token, + activated_at: t, + email: license.email, + }); +} + +// ============================================================ +// POST /api/license/validate +// ============================================================ +export async function handleValidate(req: Request, env: Env): Promise { + const body = await readJson<{ token?: string; device_id?: string; app_version?: string }>(req); + const token = (body.token || "").trim(); + const deviceId = (body.device_id || "").trim(); + if (!token || !deviceId) return badRequest("Missing token or device_id"); + + const claims = await verifyJwt(token, env.JWT_SECRET); + if (!claims) return unauthorized("Token invalido o scaduto"); + if (claims.device_id !== deviceId) { + return unauthorized("device_id mismatch"); + } + + const license = await env.DB.prepare( + `SELECT id, license_key, email, status FROM licenses WHERE id = ?1` + ).bind(claims.sub).first(); + if (!license || license.status !== "active") { + return unauthorized("Licenza non attiva"); + } + + const act = await findActivation(env, license.id, deviceId); + if (!act || act.revoked_at !== null) { + return unauthorized("Attivazione non trovata o revocata"); + } + + await env.DB.prepare( + `UPDATE activations SET last_seen_at=?1, app_version=?2 WHERE id=?3` + ).bind(now(), body.app_version || act.app_version, act.id).run(); + + // Rotazione token: ne emettiamo uno nuovo per estendere l'exp + const fresh = await issueToken(env, license, deviceId); + return json({ token: fresh, email: license.email }); +} + +// ============================================================ +// POST /api/license/deactivate +// ============================================================ +export async function handleDeactivate(req: Request, env: Env): Promise { + const body = await readJson<{ token?: string; device_id?: string }>(req); + const token = (body.token || "").trim(); + const deviceId = (body.device_id || "").trim(); + if (!token || !deviceId) return badRequest("Missing token or device_id"); + + const claims = await verifyJwt(token, env.JWT_SECRET); + if (!claims) return unauthorized("Token invalido"); + if (claims.device_id !== deviceId) return unauthorized("device_id mismatch"); + + await env.DB.prepare( + `UPDATE activations SET revoked_at=?1 + WHERE license_id=?2 AND device_id=?3 AND revoked_at IS NULL` + ).bind(now(), claims.sub, deviceId).run(); + + return json({ ok: true }); +} diff --git a/server/src/updates.ts b/server/src/updates.ts new file mode 100644 index 0000000..8bba596 --- /dev/null +++ b/server/src/updates.ts @@ -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¤t=v1.5.2 + * Authorization: Bearer (opzionale ma necessario per ricevere download_url) + * + * Risposta: + * { + * version, notes, sha256, + * download_url (firmato, scade in DOWNLOAD_URL_TTL_SECONDS) -- solo se token valido + * } + */ +export async function handleLatest(req: Request, env: Env): Promise { + const url = new URL(req.url); + const platform = (url.searchParams.get("platform") || "").toLowerCase(); + if (platform !== "macos" && platform !== "windows") { + return badRequest("platform must be macos or windows"); + } + + const row = await env.DB.prepare( + `SELECT version, platform, r2_key, size_bytes, sha256, notes, published_at + FROM releases + WHERE platform = ?1 + ORDER BY published_at DESC + LIMIT 1` + ).bind(platform).first(); + + if (!row) { + return json({ + version: env.LATEST_VERSION || "", + notes: "", + download_url: "", + requires_license: true, + }); + } + + // Auth opzionale: senza token rispondiamo solo con metadata (version + notes). + const auth = req.headers.get("Authorization") || ""; + let licensed = false; + if (auth.startsWith("Bearer ")) { + const token = auth.slice(7).trim(); + const claims = await verifyJwt(token, env.JWT_SECRET); + licensed = !!claims; + } + + let downloadUrl = ""; + if (licensed) { + // R2 non genera URL firmati nativi via Workers SDK in modo semplice. + // Soluzione: serviamo il file via questo Worker su un path firmato HMAC + // con scadenza. /api/download?key=&exp=&sig= + downloadUrl = await signDownloadUrl(env, row.r2_key); + } + + return json({ + version: row.version, + notes: row.notes || "", + sha256: row.sha256 || "", + size_bytes: row.size_bytes || 0, + download_url: downloadUrl, + requires_license: !licensed, + }); +} + +async function signDownloadUrl(env: Env, r2Key: string): Promise { + // Implementazione minima: torniamo un URL relativo che un altro endpoint + // /api/download verifichera prima di servire il file da R2. + // Per ora restituisco un placeholder; vai a implementare /api/download + // in un secondo passaggio se vuoi servire i binari dietro firma. + const ttl = parseInt(env.DOWNLOAD_URL_TTL_SECONDS || "300", 10); + const exp = Math.floor(Date.now() / 1000) + ttl; + const payload = `${r2Key}.${exp}`; + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(env.JWT_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, ["sign"], + ); + const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); + const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf))) + .replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); + return `https://musictools.djluza.com/api/download?key=${encodeURIComponent(r2Key)}&exp=${exp}&sig=${sig}`; +} diff --git a/server/src/worker.ts b/server/src/worker.ts new file mode 100644 index 0000000..4ded2bd --- /dev/null +++ b/server/src/worker.ts @@ -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¤t=v1.5.2 [Authorization: Bearer ] + * POST /api/webhook/lemonsqueezy (firmato HMAC, crea licenza dopo ordine) + * + * Auth model: + * - L'app non ha account: la "verita" e' (license_key, email). + * - Dopo activate(), il server emette un JWT HMAC con claims + * { sub: license_id, key_id, email, device_id, iat, exp }. + * Il client lo salva e lo manda a ogni revalidate / /api/latest. + * - revoke = update licenses.status='revoked' + tutti i validate falliscono. + */ + +import { handleActivate, handleValidate, handleDeactivate } from "./license"; +import { handleLatest } from "./updates"; +import { handleLemonSqueezyWebhook } from "./lemonsqueezy"; +import { json, methodNotAllowed, notFound } from "./http"; + +export interface Env { + DB: D1Database; + BUILDS: R2Bucket; + JWT_SECRET: string; + LEMONSQUEEZY_SIGNING_SECRET: string; + RESEND_API_KEY: string; + LATEST_VERSION: string; + MAX_ACTIVATIONS: string; + TOKEN_TTL_DAYS: string; + DOWNLOAD_URL_TTL_SECONDS: string; +} + +export default { + async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method.toUpperCase(); + + // CORS (utile se in futuro vuoi chiamare l'API dalla landing page) + if (method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Max-Age": "86400", + }, + }); + } + + try { + if (path === "/api/license/activate") { + if (method !== "POST") return methodNotAllowed(); + return await handleActivate(req, env); + } + if (path === "/api/license/validate") { + if (method !== "POST") return methodNotAllowed(); + return await handleValidate(req, env); + } + if (path === "/api/license/deactivate") { + if (method !== "POST") return methodNotAllowed(); + return await handleDeactivate(req, env); + } + if (path === "/api/latest") { + if (method !== "GET") return methodNotAllowed(); + return await handleLatest(req, env); + } + if (path === "/api/webhook/lemonsqueezy") { + if (method !== "POST") return methodNotAllowed(); + return await handleLemonSqueezyWebhook(req, env); + } + if (path === "/api/health") { + return json({ ok: true, version: env.LATEST_VERSION }); + } + return notFound(); + } catch (err) { + console.error("Unhandled error:", err); + return json({ error: "Internal error" }, 500); + } + }, +}; diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..97252db --- /dev/null +++ b/server/tsconfig.json @@ -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"] +} diff --git a/server/wrangler.toml b/server/wrangler.toml new file mode 100644 index 0000000..1fc8b33 --- /dev/null +++ b/server/wrangler.toml @@ -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 diff --git a/webui/css/style.css b/webui/css/style.css index c050418..0975afa 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -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; +} diff --git a/webui/index.html b/webui/index.html index a1cfc62..c7fd68d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -520,6 +520,22 @@ + +
+
+
+ + +
+
+ ⓘ +
+ 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. +
+
+
+
@@ -813,6 +829,44 @@
+ + +