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:
1 parent
05a2a58ace
commit
5202b2c586
19 files changed
+1571
-30
No files matched your search
@@ -43,3 +43,9 @@ PlayList_da_scaricare.txt
|
|||||||
|
|
||||||
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
|
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
|
||||||
assets/icon.iconset/
|
assets/icon.iconset/
|
||||||
|
|
||||||
|
# Backend (server/) build artifacts e secrets
|
||||||
|
server/node_modules/
|
||||||
|
server/.wrangler/
|
||||||
|
server/.dev.vars
|
||||||
|
server/dist/
|
||||||
+77
-30
@@ -10,7 +10,8 @@ import time
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from typing import Any, Optional
|
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 (
|
from core.downloader import (
|
||||||
download_playlist,
|
download_playlist,
|
||||||
download_direct_url,
|
download_direct_url,
|
||||||
@@ -113,10 +114,15 @@ class Api:
|
|||||||
def get_init_data(self) -> dict:
|
def get_init_data(self) -> dict:
|
||||||
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
||||||
cfg = load_config()
|
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 {
|
return {
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"config": cfg,
|
"config": safe_cfg,
|
||||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||||
|
"license": license_status,
|
||||||
|
"purchase_url": "https://musictools.djluza.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -128,7 +134,10 @@ class Api:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
threshold = 310
|
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_id": (payload.get("client_id") or "").strip(),
|
||||||
"client_secret": (payload.get("client_secret") or "").strip(),
|
"client_secret": (payload.get("client_secret") or "").strip(),
|
||||||
"bitrate": payload.get("bitrate", "320K"),
|
"bitrate": payload.get("bitrate", "320K"),
|
||||||
@@ -136,40 +145,73 @@ class Api:
|
|||||||
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
||||||
"output_dir": (payload.get("output_dir") or "").strip(),
|
"output_dir": (payload.get("output_dir") or "").strip(),
|
||||||
"theme": payload.get("theme", "dark"),
|
"theme": payload.get("theme", "dark"),
|
||||||
}
|
})
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def check_update(self) -> dict:
|
# ------------------------------------------------------------------
|
||||||
"""Controlla aggiornamenti interrogando l'API GitHub Releases."""
|
# Licenza
|
||||||
import platform
|
# ------------------------------------------------------------------
|
||||||
import requests
|
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:
|
try:
|
||||||
resp = requests.get(API_URL, headers={
|
status = license_mod.activate(
|
||||||
"User-Agent": "MusicTools",
|
payload.get("key") or "",
|
||||||
"Accept": "application/vnd.github+json",
|
payload.get("email") or "",
|
||||||
}, timeout=10)
|
)
|
||||||
resp.raise_for_status()
|
return {"ok": True, "license": status}
|
||||||
data = resp.json()
|
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", "")
|
def deactivate_license(self) -> dict:
|
||||||
notes = data.get("body", "") or ""
|
status = license_mod.deactivate(release_remote=True)
|
||||||
html_url = data.get("html_url", "")
|
return {"ok": True, "license": status}
|
||||||
|
|
||||||
# Trova l'asset giusto per la piattaforma
|
def revalidate_license(self) -> dict:
|
||||||
assets = data.get("assets", []) or []
|
"""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"
|
is_macos = platform.system() == "Darwin"
|
||||||
keyword = "macos" if is_macos else "windows"
|
plat = "macos" if is_macos else "windows"
|
||||||
download_url = ""
|
|
||||||
for a in assets:
|
url = f"{LICENSE_API_URL.rstrip('/')}/api/latest?platform={plat}¤t={VERSION}"
|
||||||
name = (a.get("name") or "").lower()
|
headers = {
|
||||||
if keyword in name and name.endswith(".zip"):
|
"User-Agent": f"MusicTools/{VERSION}",
|
||||||
download_url = a.get("browser_download_url", "")
|
"Accept": "application/json",
|
||||||
break
|
}
|
||||||
if not download_url:
|
if token:
|
||||||
download_url = html_url # fallback: pagina release
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -177,8 +219,13 @@ class Api:
|
|||||||
"remote": remote,
|
"remote": remote,
|
||||||
"is_new": bool(remote and remote != VERSION),
|
"is_new": bool(remote and remote != VERSION),
|
||||||
"download_url": download_url,
|
"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:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "current": VERSION}
|
return {"ok": False, "error": str(e), "current": VERSION}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ VERSION = "v1.5.2"
|
|||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
_LEGACY_NAME = "MusicDownload"
|
_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:
|
def _get_config_dir() -> Path:
|
||||||
"""Ritorna la directory per config.json.
|
"""Ritorna la directory per config.json.
|
||||||
@@ -59,6 +70,13 @@ DEFAULTS = {
|
|||||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||||
"output_dir": str(_project_dir / "MUSICA"),
|
"output_dir": str(_project_dir / "MUSICA"),
|
||||||
"theme": "dark",
|
"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
@@ -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)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
*.log
|
||||||
|
dist/
|
||||||
@@ -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€.
|
||||||
@@ -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);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 <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}`;
|
||||||
|
}
|
||||||
@@ -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 <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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1256,3 +1256,157 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
max-height: none;
|
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;
|
||||||
|
}
|
||||||
@@ -520,6 +520,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<h2 class="section-label">Aggiornamenti</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row spread">
|
<div class="row spread">
|
||||||
@@ -813,6 +829,44 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</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 ============== -->
|
<!-- ============== MODAL: Spotify Guide ============== -->
|
||||||
<div class="modal-backdrop" id="guideModal" hidden>
|
<div class="modal-backdrop" id="guideModal" hidden>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|||||||
+127
@@ -7,6 +7,8 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
config: {},
|
config: {},
|
||||||
|
license: { licensed: false },
|
||||||
|
purchaseUrl: "https://musictools.djluza.com",
|
||||||
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
||||||
dlOutputDir: "",
|
dlOutputDir: "",
|
||||||
upDir: "",
|
upDir: "",
|
||||||
@@ -45,6 +47,90 @@ function parseQueryToTrack(q) {
|
|||||||
return { artist: "", name: s };
|
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
|
// Bridge — eventi spinti dal backend
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -234,10 +320,15 @@ async function init() {
|
|||||||
await waitApi();
|
await waitApi();
|
||||||
const data = await window.pywebview.api.get_init_data();
|
const data = await window.pywebview.api.get_init_data();
|
||||||
state.config = data.config || {};
|
state.config = data.config || {};
|
||||||
|
state.license = data.license || { licensed: false };
|
||||||
|
state.purchaseUrl = data.purchase_url || "https://musictools.djluza.com";
|
||||||
$("#footerVersion").textContent = data.version;
|
$("#footerVersion").textContent = data.version;
|
||||||
$("#currentVersionLabel").textContent = data.version;
|
$("#currentVersionLabel").textContent = data.version;
|
||||||
$("#guideBody").textContent = data.spotify_guide;
|
$("#guideBody").textContent = data.spotify_guide;
|
||||||
|
|
||||||
|
applyLicenseGate();
|
||||||
|
renderLicenseStatus();
|
||||||
|
|
||||||
// Populate Settings fields
|
// Populate Settings fields
|
||||||
$("#clientIdInput").value = state.config.client_id || "";
|
$("#clientIdInput").value = state.config.client_id || "";
|
||||||
$("#clientSecretInput").value = state.config.client_secret || "";
|
$("#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
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user