diff --git a/api/bridge.py b/api/bridge.py index c1dbcfc..aaa512d 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -123,6 +123,7 @@ class Api: "config": safe_cfg, "spotify_guide": SPOTIFY_GUIDE_TEXT, "license": license_status, + "plan": license_mod.get_plan(cfg), "purchase_url": "https://musictools.djluza.com", } @@ -181,6 +182,63 @@ class Api: webbrowser.open("https://musictools.djluza.com") return {"ok": True} + # ------------------------------------------------------------------ + # Quota giornaliera + # ------------------------------------------------------------------ + def get_quota_status(self) -> dict: + """Ritorna lo stato quota corrente (lettura senza incremento).""" + try: + data = license_mod.get_quota_status() + return {"ok": True, "quota": data} + except license_mod.LicenseNetworkError as e: + return {"ok": False, "offline": True, "error": str(e)} + except license_mod.LicenseError as e: + return {"ok": False, "error": str(e)} + + def _gate(self, feature: str) -> Optional[dict]: + """Verifica feature + quota prima di un'azione. + + Ritorna None se l'azione e' permessa; altrimenti un dict di + errore pronto per la UI (con eventuale info quota per il modal + "limite raggiunto"). + """ + # 1) Feature gate locale (dai claims JWT in config) + if not license_mod.has_feature(feature): + plan = license_mod.get_plan() + plan_name = plan.get("name") or "il tuo piano" + return { + "ok": False, + "reason": "feature_not_in_plan", + "feature": feature, + "error": ( + f"Questa funzione non e' inclusa in {plan_name}. " + "Passa a un piano superiore per sbloccarla." + ), + "plan": plan, + } + # 2) Quota gate server-side + try: + res = license_mod.consume_quota(feature) + except license_mod.LicenseNetworkError as e: + return { + "ok": False, "reason": "offline", "offline": True, + "error": f"Impossibile contattare il server: {e}", + } + except license_mod.LicenseError as e: + return {"ok": False, "reason": "license_invalid", "error": str(e)} + + if not res.get("allowed", True): + return { + "ok": False, + "reason": "quota_exceeded", + "feature": feature, + "error": res.get("error") or "Limite giornaliero raggiunto.", + "quota": res, + } + # OK: notifico la UI dello stato quota aggiornato + self._emit("quota:update", res) + return None + def check_update(self) -> dict: """Controlla aggiornamenti interrogando l'API djluza.com. @@ -326,6 +384,10 @@ class Api: if not os.path.exists(path): return {"ok": False, "error": "File non trovato"} + gate = self._gate("metadata") + if gate: + return gate + data = payload.get("data") or {} cover_path = (payload.get("cover_path") or "").strip() or None remove_cover = bool(payload.get("remove_cover", False)) @@ -360,6 +422,10 @@ class Api: if not filename.lower().endswith(".mp3"): filename += ".mp3" + gate = self._gate("record") + if gate: + return gate + out_path = os.path.join(output_dir, filename) def cb(status, payload_evt): @@ -412,6 +478,10 @@ class Api: if not output_dir: return {"ok": False, "error": "Cartella output non impostata"} + gate = self._gate("audio") + if gate: + return gate + self._download_thread = threading.Thread( target=self._download_worker, args=(list(urls), output_dir), @@ -441,6 +511,10 @@ class Api: if safe: output_dir = os.path.join(output_dir, safe) + gate = self._gate("audio") + if gate: + return gate + self._download_thread = threading.Thread( target=self._tracks_worker, args=(list(tracks), output_dir), @@ -627,6 +701,10 @@ class Api: if not directory: return {"ok": False, "error": "Cartella non impostata"} + gate = self._gate("upgrade") + if gate: + return gate + cfg = load_config() cookies_path = cfg.get("cookies_path", "") @@ -700,6 +778,11 @@ class Api: if not output_dir: return {"ok": False, "error": "Cartella output non impostata"} + # Anche "audio_only" video count come video: usa codec/yt-dlp diversi + gate = self._gate("video") + if gate: + return gate + self._video_thread = threading.Thread( target=self._video_worker, args=(list(urls), output_dir, quality, audio_only), diff --git a/core/config.py b/core/config.py index 2cdc91b..300afb3 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.6.0" +VERSION = "v1.7.0" APP_NAME = "MusicTools" @@ -77,6 +77,14 @@ DEFAULTS = { "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 + # ---- Piano (snapshot dei claims JWT) ---- + "plan_code": "", # "basic" | "pro" | "premium" | "annual" + "plan_name": "", # nome user-facing + "plan_features": [], # ["audio", "video", "record", "metadata", "upgrade"] + "plan_daily_limit": None, # None = unlimited (annual), altrimenti int + "plan_is_subscription": False, + "plan_expires_at": 0, # solo per annual one-time + "plan_period_end": 0, # solo per subscription mensili } diff --git a/core/license.py b/core/license.py index 33372c8..0f52506 100644 --- a/core/license.py +++ b/core/license.py @@ -28,6 +28,7 @@ import json import platform import time import urllib.error +import urllib.parse import urllib.request import uuid from typing import Optional @@ -140,12 +141,42 @@ def _http_post(path: str, body: dict) -> dict: # ============================================================ # API pubblica # ============================================================ +def _plan_snapshot(cfg: dict) -> dict: + """Estrae i dati del piano dal config (popolati da activate/validate).""" + code = (cfg.get("plan_code") or "").strip() + if not code: + return {} + return { + "code": code, + "name": (cfg.get("plan_name") or "").strip() or code.title(), + "features": list(cfg.get("plan_features") or []), + "daily_limit": cfg.get("plan_daily_limit"), # puo' essere None + "is_subscription": bool(cfg.get("plan_is_subscription")), + "expires_at": int(cfg.get("plan_expires_at") or 0), + "period_end": int(cfg.get("plan_period_end") or 0), + } + + +def has_feature(name: str, config: Optional[dict] = None) -> bool: + """True se il piano corrente include la feature richiesta.""" + cfg = config or load_config() + plan = _plan_snapshot(cfg) + if not plan: + return False + return name in (plan.get("features") or []) + + +def get_plan(config: Optional[dict] = None) -> dict: + """Ritorna il dict del piano corrente, oppure {} se non noto.""" + return _plan_snapshot(config or load_config()) + + 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). + needs_revalidation (bool), plan (dict). """ cfg = config or load_config() token = (cfg.get("license_token") or "").strip() @@ -153,6 +184,7 @@ def get_status(config: Optional[dict] = None) -> dict: 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) + plan = _plan_snapshot(cfg) if not token or not key: return { @@ -164,9 +196,27 @@ def get_status(config: Optional[dict] = None) -> dict: "last_validated_at": 0, "days_since_validation": 0, "needs_revalidation": False, + "plan": plan, } - days_since = (_now() - last_val) // 86400 if last_val else 999 + # Scadenza locale derivata dai claims (best-effort: la verita' e' sul server). + t = _now() + expires_at = plan.get("expires_at") or 0 + period_end = plan.get("period_end") or 0 + if expires_at and expires_at < t: + return { + "licensed": False, "reason": "plan_expired", "email": email, "key": key, + "activated_at": activated, "last_validated_at": last_val, + "days_since_validation": 0, "needs_revalidation": True, "plan": plan, + } + if period_end and period_end < t: + return { + "licensed": False, "reason": "subscription_expired", "email": email, "key": key, + "activated_at": activated, "last_validated_at": last_val, + "days_since_validation": 0, "needs_revalidation": True, "plan": plan, + } + + days_since = (t - last_val) // 86400 if last_val else 999 needs_reval = days_since >= LICENSE_REVALIDATE_DAYS grace_expired = days_since >= LICENSE_GRACE_DAYS @@ -180,6 +230,7 @@ def get_status(config: Optional[dict] = None) -> dict: "last_validated_at": last_val, "days_since_validation": days_since, "needs_revalidation": True, + "plan": plan, } return { @@ -191,6 +242,7 @@ def get_status(config: Optional[dict] = None) -> dict: "last_validated_at": last_val, "days_since_validation": days_since, "needs_revalidation": needs_reval, + "plan": plan, } @@ -199,6 +251,55 @@ def is_licensed() -> bool: return get_status()["licensed"] +def _store_plan_from_response(cfg: dict, resp: dict, token: str) -> None: + """Aggiorna il config con i campi del piano estratti dalla risposta API. + + Server risposta puo' contenere: + - plan: {code, name, daily_limit, features, is_subscription} + - expires_at: epoch (annual) + - period_end: epoch (subscription) + Come fallback decodifica i claims dal JWT. + """ + plan = resp.get("plan") if isinstance(resp.get("plan"), dict) else None + claims = _decode_jwt_claims(token) if token else {} + + code = "" + name = "" + features = [] + daily_limit = None + is_sub = False + if plan: + code = (plan.get("code") or "").strip() + name = (plan.get("name") or "").strip() + features = list(plan.get("features") or []) + daily_limit = plan.get("daily_limit") + is_sub = bool(plan.get("is_subscription")) + else: + code = (claims.get("plan") or "").strip() + name = (claims.get("plan_name") or "").strip() + features = list(claims.get("features") or []) + daily_limit = claims.get("daily_limit") + is_sub = bool(claims.get("is_subscription")) + + cfg["plan_code"] = code + cfg["plan_name"] = name + cfg["plan_features"] = features + cfg["plan_daily_limit"] = daily_limit # None ammesso (unlimited) + cfg["plan_is_subscription"] = is_sub + cfg["plan_expires_at"] = int(resp.get("expires_at") or claims.get("expires_at") or 0) + cfg["plan_period_end"] = int(resp.get("period_end") or claims.get("period_end") or 0) + + +def _clear_plan(cfg: dict) -> None: + cfg["plan_code"] = "" + cfg["plan_name"] = "" + cfg["plan_features"] = [] + cfg["plan_daily_limit"] = None + cfg["plan_is_subscription"] = False + cfg["plan_expires_at"] = 0 + cfg["plan_period_end"] = 0 + + def activate(license_key: str, email: str) -> dict: """Attiva una licenza contro il server. Salva token su success. @@ -231,6 +332,7 @@ def activate(license_key: str, email: str) -> dict: cfg["license_token"] = token cfg["license_activated_at"] = int(resp.get("activated_at") or now) cfg["last_validated_at"] = now + _store_plan_from_response(cfg, resp, token) save_config(cfg) return get_status(cfg) @@ -260,6 +362,7 @@ def validate() -> dict: # Server ha risposto 4xx -> token non piu valido cfg["license_token"] = "" cfg["last_validated_at"] = 0 + _clear_plan(cfg) save_config(cfg) return get_status(cfg) @@ -268,6 +371,7 @@ def validate() -> dict: if new_token: cfg["license_token"] = new_token cfg["last_validated_at"] = _now() + _store_plan_from_response(cfg, resp, cfg.get("license_token", "")) save_config(cfg) return get_status(cfg) @@ -298,5 +402,86 @@ def deactivate(release_remote: bool = True) -> dict: cfg["license_token"] = "" cfg["license_activated_at"] = 0 cfg["last_validated_at"] = 0 + _clear_plan(cfg) save_config(cfg) return get_status(cfg) + + +# ============================================================ +# Quota giornaliera +# ============================================================ +def _http_json(method: str, path: str, body: Optional[dict] = None, + token: Optional[str] = None, timeout: int = 10) -> dict: + """Helper unificato per GET/POST con Authorization Bearer.""" + url = LICENSE_API_URL.rstrip("/") + path + data = json.dumps(body).encode("utf-8") if body is not None else None + headers = { + "User-Agent": f"MusicTools/{VERSION}", + "Accept": "application/json", + } + if data is not None: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=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) if err_body else {} + except Exception: + err_data = {} + err_data.setdefault("error", e.reason or f"HTTP {e.code}") + err_data["_status"] = e.code + return err_data + 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)) + + +def get_quota_status() -> dict: + """Legge lo stato quota dal server. Ritorna dict con plan/used/limit/remaining. + + Solleva LicenseError se il server rifiuta (401/403); LicenseNetworkError + in caso di problemi di rete. + """ + cfg = load_config() + token = (cfg.get("license_token") or "").strip() + if not token: + raise LicenseError("Licenza non attivata.") + resp = _http_json("GET", "/api/usage/status", token=token) + if resp.get("_status") and resp["_status"] >= 400: + raise LicenseError(resp.get("error") or "Errore stato quota.") + return resp + + +def consume_quota(feature: str = "") -> dict: + """Incrementa la quota giornaliera prima di un download. + + Ritorna {allowed, used, limit, remaining, plan, day}. + Se il server risponde 429 -> allowed=False (l'utente ha raggiunto il limite). + Per i piani unlimited (annual), allowed=True sempre. + Solleva LicenseError per 401/403; LicenseNetworkError per problemi di rete. + """ + cfg = load_config() + token = (cfg.get("license_token") or "").strip() + if not token: + raise LicenseError("Licenza non attivata.") + body = {"feature": feature} if feature else {} + resp = _http_json("POST", "/api/usage/consume", body=body, token=token) + status_code = resp.pop("_status", 0) + if status_code == 401 or status_code == 403: + raise LicenseError(resp.get("error") or "Licenza non valida.") + if status_code == 429: + # Limite raggiunto, ma e' una risposta strutturata: ritorniamo + # il dict cosi' la UI puo' mostrare i numeri. + resp.setdefault("allowed", False) + return resp + if status_code and status_code >= 400: + raise LicenseError(resp.get("error") or f"Errore quota ({status_code}).") + resp.setdefault("allowed", True) + return resp diff --git a/landing/css/style.css b/landing/css/style.css index 932b6d5..ed94d3d 100644 --- a/landing/css/style.css +++ b/landing/css/style.css @@ -555,6 +555,52 @@ a { color: inherit; text-decoration: none; } margin: 14px 0 0; } +/* Grid 4 colonne per i piani */ +.price-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 32px; + align-items: stretch; +} +@media (max-width: 1100px) { + .price-grid { grid-template-columns: repeat(2, 1fr); } +} +@media (max-width: 640px) { + .price-grid { grid-template-columns: 1fr; } +} + +.price-card-sm { + margin: 0; + max-width: none; + padding: 32px 22px 24px; + display: flex; + flex-direction: column; + box-shadow: none; + border: 1px solid var(--border); +} +.price-card-sm .price-amount { font-size: 44px; } +.price-card-sm .price-currency { font-size: 20px; } +.price-card-sm .price-once { font-size: 13px; } +.price-card-sm .price-features { margin-bottom: 22px; flex: 1; } +.price-card-sm .price-features li { padding: 7px 0; font-size: 13.5px; } + +.price-card-featured { + border-color: var(--accent); + box-shadow: 0 18px 48px rgba(29, 185, 84, 0.18); +} + +.price-badge-gold { + background: linear-gradient(135deg, #f8c44a, #f4a51c); + color: #0a0a0a; +} + +.price-shared { + max-width: 720px; + margin: 0 auto; + text-align: center; +} + /* ============================================================ FAQ ============================================================ */ diff --git a/landing/index.html b/landing/index.html index cfa6f12..8b44261 100644 --- a/landing/index.html +++ b/landing/index.html @@ -4,10 +4,10 @@ MusicTools — Scarica, modifica e registra la tua musica - + - + @@ -26,11 +26,11 @@ - Compra — 39,90 € + Vedi i piani @@ -38,7 +38,7 @@
- Per Mac e Windows · Licenza a vita + Per Mac e Windows · Da 2,99 €/mese

La tua musica, ovunque la trovi.
In un'unica app.

Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K. @@ -46,12 +46,12 @@ Modifica i metadati e le copertine. Tutto offline, senza account.

    -
  • ✓ Acquisto una tantum, nessun abbonamento
  • -
  • ✓ Aggiornamenti gratuiti a vita
  • +
  • ✓ Mensili da 2,99 € o annuale 49,90 € una tantum
  • +
  • ✓ Aggiornamenti gratuiti inclusi
  • ✓ Funziona offline · Su 3 dispositivi
@@ -175,32 +175,104 @@
- PREZZO -

Una licenza. Per sempre.

-

Niente abbonamenti. Niente sorprese. Compri una volta, la usi per sempre.

+ PIANI +

Scegli il piano giusto per te

+

Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.

-
-
Più popolare
-
MusicTools Personal
-
- 39,90 - € - una tantum +
+ + +
+
Basic
+
+ 2,99 + € + /mese +
+
    +
  • ✓ Download brani da Spotify e YouTube
  • +
  • ✓ Fino a 10 al giorno
  • +
  • ✓ MP3 fino a 320kbps
  • +
  • ✓ 3 dispositivi (Mac + Win)
  • +
  • ✗ Download video
  • +
  • ✗ Registrazione, metadati, upgrade libreria
  • +
+ + Inizia con Basic +
-
    -
  • ✓ Licenza a vita — paghi una volta, mai più
  • -
  • ✓ 3 dispositivi — Mac e Windows mixati
  • -
  • ✓ Aggiornamenti gratuiti per tutte le versioni future
  • -
  • ✓ Tutte le 7 tab sbloccate
  • -
  • ✓ Supporto via email a info@djluza.com
  • -
  • ✓ Garanzia 14 giorni soddisfatti o rimborsati
  • -
- - Acquista ora — 39,90 € - -

Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa

+ + +
+
Più scelto
+
Pro
+
+ 5,99 + € + /mese +
+
    +
  • ✓ Tutto di Basic
  • +
  • ✓ Download video (YT, TikTok, IG, FB)
  • +
  • ✓ Fino a 30 al giorno
  • +
  • ✓ Registrazione audio (BlackHole / loopback)
  • +
  • ✓ Editor metadati + copertine
  • +
  • ✓ Upgrade qualità libreria
  • +
+ + Scegli Pro + +
+ + +
+
Premium
+
+ 9,99 + € + /mese +
+
    +
  • ✓ Tutto di Pro
  • +
  • ✓ Fino a 150 al giorno
  • +
  • ✓ Priorità nel supporto via email
  • +
  • ✓ 3 dispositivi (Mac + Win)
  • +
+ + Passa a Premium + +
+ + + + +
+ +
+

Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy

Su quanti dispositivi posso installarla? -

Una licenza copre fino a 3 dispositivi, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.

+

Tutti i piani coprono fino a 3 dispositivi, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.

+
+ +
+ Come funziona il limite giornaliero? +

Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a mezzanotte ora di Roma. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.

+
+ +
+ Posso passare a un piano superiore? +

Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.

+
+ +
+ Cosa succede quando scade l'abbonamento annuale? +

Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.

Come funzionano gli aggiornamenti? -

Sono gratis a vita. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.

+

Sono inclusi in tutti i piani. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.

@@ -307,8 +394,8 @@

Pronto a semplificarti la vita?

-

Una sola app per tutto quello che fai con la musica.

- Compra ora — 39,90 € +

Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.

+ Vedi i piani
diff --git a/server/migrations/0002_plans_and_usage.sql b/server/migrations/0002_plans_and_usage.sql new file mode 100644 index 0000000..ffb3685 --- /dev/null +++ b/server/migrations/0002_plans_and_usage.sql @@ -0,0 +1,28 @@ +SET NAMES utf8mb4; + +ALTER TABLE licenses + ADD COLUMN plan VARCHAR(16) NULL AFTER status, + ADD COLUMN daily_limit INT UNSIGNED NULL AFTER plan, + ADD COLUMN subscription_id VARCHAR(64) NULL AFTER daily_limit, + ADD COLUMN current_period_end BIGINT UNSIGNED NULL AFTER subscription_id, + ADD COLUMN expires_at BIGINT UNSIGNED NULL AFTER current_period_end; + +UPDATE licenses + SET plan = 'annual', + daily_limit = NULL, + expires_at = NULL + WHERE plan IS NULL; + +ALTER TABLE licenses + ADD KEY idx_licenses_plan (plan), + ADD KEY idx_licenses_sub (subscription_id); + +CREATE TABLE IF NOT EXISTS daily_usage ( + license_id INT UNSIGNED NOT NULL, + day CHAR(10) NOT NULL, + count INT UNSIGNED NOT NULL DEFAULT 0, + updated_at BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (license_id, day), + CONSTRAINT fk_daily_usage_license + FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/email.js b/server/src/email.js index a059873..6853917 100644 --- a/server/src/email.js +++ b/server/src/email.js @@ -1,21 +1,72 @@ /** - * Invio email transazionali via Resend (https://resend.com). - * Usa fetch nativo di Node >= 20 — nessuna dipendenza. + * Invio email transazionali via Resend. + * + * sendLicenseEmail(to, key, { plan, expiresAt, periodEnd }) + * - plan: codice piano ("basic" | "pro" | "premium" | "annual") + * - expiresAt: epoch seconds (annual one-time) o null + * - periodEnd: epoch seconds (subscription mensile) o null * * Variabili env richieste: * RESEND_API_KEY dal dashboard Resend * EMAIL_FROM "MusicTools " (dominio verificato) */ +import { getPlan } from "./plans.js"; + const FROM = process.env.EMAIL_FROM || "MusicTools "; const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com"; -export async function sendLicenseEmail(to, licenseKey) { +function fmtItalianDate(epochSeconds) { + if (!epochSeconds) return ""; + const d = new Date(Number(epochSeconds) * 1000); + return new Intl.DateTimeFormat("it-IT", { + timeZone: "Europe/Rome", + day: "2-digit", + month: "long", + year: "numeric", + }).format(d); +} + +function describePlan(planCode, expiresAt, periodEnd) { + const p = getPlan(planCode); + if (!p) { + return { + name: "MusicTools", + summary: "La tua licenza e' attiva.", + }; + } + const features = { + audio: "download brani audio", + video: "download video", + record: "registrazione audio", + metadata: "editor metadati", + upgrade: "upgrade qualita' libreria", + }; + const featList = p.features.map((f) => features[f] || f).join(", "); + const limit = p.daily_limit === null + ? "senza limiti giornalieri" + : `fino a ${p.daily_limit} download al giorno`; + let validity = ""; + if (planCode === "annual" && expiresAt) { + validity = `Validita': 1 anno, scade il ${fmtItalianDate(expiresAt)}.`; + } else if (p.is_subscription && periodEnd) { + validity = `Prossimo rinnovo: ${fmtItalianDate(periodEnd)}.`; + } + return { + name: p.name, + summary: `Piano ${p.name} — ${limit}. Funzioni incluse: ${featList}. ${validity}`, + }; +} + +export async function sendLicenseEmail(to, licenseKey, opts = {}) { if (!process.env.RESEND_API_KEY) { console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to); return; } + const { plan: planCode, expiresAt, periodEnd } = opts; + const planInfo = describePlan(planCode, expiresAt, periodEnd); + const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`; const macUrl = `${BASE}/api/download/macos?${q}`; const winUrl = `${BASE}/api/download/windows?${q}`; @@ -23,8 +74,11 @@ export async function sendLicenseEmail(to, licenseKey) { const html = `

Grazie per aver scelto MusicTools!

+

+ ${planInfo.summary} +

- Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app. + Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.

${licenseKey} @@ -35,12 +89,12 @@ export async function sendLicenseEmail(to, licenseKey) { - 🍎 Scarica per Mac + Scarica per Mac - 🪟 Scarica per Windows + Scarica per Windows @@ -49,7 +103,7 @@ export async function sendLicenseEmail(to, licenseKey) {

Come attivare:

  1. Clicca uno dei bottoni qui sopra per scaricare l'installer
  2. -
  3. Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)
  4. +
  5. Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)
  6. Apri MusicTools: la prima schermata ti chiede email e chiave
  7. Inserisci ${to} e la chiave qui sopra
@@ -62,6 +116,10 @@ export async function sendLicenseEmail(to, licenseKey) {
`; + const subject = planInfo.name === "MusicTools" + ? "La tua licenza MusicTools" + : `La tua licenza MusicTools ${planInfo.name}`; + const resp = await fetch("https://api.resend.com/emails", { method: "POST", headers: { @@ -71,7 +129,7 @@ export async function sendLicenseEmail(to, licenseKey) { body: JSON.stringify({ from: FROM, to: [to], - subject: "La tua licenza MusicTools", + subject, html, }), }); diff --git a/server/src/lemonsqueezy.js b/server/src/lemonsqueezy.js index 79ffd4f..5b01136 100644 --- a/server/src/lemonsqueezy.js +++ b/server/src/lemonsqueezy.js @@ -1,13 +1,26 @@ /** * Webhook Lemon Squeezy. * URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy - * Eventi gestiti: order_created, order_refunded. + * + * Eventi gestiti: + * - order_created : crea licenza ANNUAL (one-time) con expires_at = now+365d. + * Per le subscription ignora: il record nasce da subscription_created. + * - order_refunded : status -> refunded. + * - subscription_created : crea licenza con plan, subscription_id, current_period_end. + * - subscription_updated : aggiorna current_period_end e plan se cambiato. + * - subscription_payment_success : estende current_period_end al nuovo renews_at. + * - subscription_payment_failed : (no-op qui) lasciamo scadere current_period_end. + * - subscription_cancelled : niente, il periodo corrente resta valido fino a current_period_end. + * - subscription_expired : status -> revoked, l'utente perde l'accesso. + * + * Tutto idempotente: ogni evento si puo' rispedire senza spaccare nulla. */ import crypto from "node:crypto"; import { one, exec } from "./db.js"; import { generateLicenseKey } from "./license.js"; import { sendLicenseEmail } from "./email.js"; +import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js"; const now = () => Math.floor(Date.now() / 1000); @@ -21,8 +34,210 @@ function timingSafe(a, b) { return crypto.timingSafeEqual(A, B); } +/** Converte una data ISO (es. "2026-07-09T13:24:00Z") in epoch seconds, o null. */ +function isoToEpoch(iso) { + if (!iso) return null; + const t = Date.parse(String(iso)); + if (Number.isNaN(t)) return null; + return Math.floor(t / 1000); +} + +/** Estrae variant_id dal payload, gestendo le diverse forme degli eventi. */ +function extractVariantId(payload, attrs) { + // subscription_*: attrs.variant_id (diretto) + if (attrs.variant_id != null) return String(attrs.variant_id); + // order_created: attrs.first_order_item.variant_id + const item = attrs.first_order_item; + if (item && item.variant_id != null) return String(item.variant_id); + // alcune forme passano da relationships + const rel = payload?.data?.relationships?.variant?.data?.id; + if (rel) return String(rel); + return ""; +} + +/** Email cliente: nei subscription_* puo' essere customer_email. */ +function extractEmail(attrs) { + const e = attrs.user_email || attrs.customer_email || attrs.email || ""; + return String(e).trim().toLowerCase(); +} + +// ---- Handlers per famiglia evento ----------------------------------------- + +async function handleOrderCreated(payload, attrs, t) { + const email = extractEmail(attrs); + const orderId = String(payload?.data?.id || attrs.order_number || ""); + if (!email || !orderId) { + return { status: 400, body: { error: "Missing email or order_id" } }; + } + const variantId = extractVariantId(payload, attrs); + const planCode = planByVariantId(variantId); + const plan = planCode ? getPlan(planCode) : null; + + // Subscriptions: il record vero arriva con subscription_created. + // Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione + // non ancora generata), accettiamo l'evento ma non creiamo nulla. + if (plan && plan.is_subscription) { + return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } }; + } + + // Annual one-time (o piano non mappato -> trattalo come annual di default + // cosi' non lasciamo l'utente senza licenza se ha pagato). + const planForRecord = plan?.code || "annual"; + const limit = plan?.daily_limit ?? null; + const expiresAt = t + ANNUAL_TTL_SECONDS; + const key = generateLicenseKey(); + + try { + await exec( + `INSERT INTO licenses + (license_key, email, status, plan, daily_limit, + source, order_id, expires_at, created_at, updated_at) + VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?)`, + [key, email, planForRecord, limit, orderId, expiresAt, t, t], + ); + } catch (e) { + if (e?.code === "ER_DUP_ENTRY") { + return { status: 200, body: { ok: true, duplicate: true } }; + } + throw e; + } + + try { + await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt }); + } catch (e) { + console.error("[email] send failed:", e?.message || e); + } + + return { status: 200, body: { ok: true, plan: planForRecord } }; +} + +async function handleOrderRefunded(payload, attrs, t) { + const orderId = String(payload?.data?.id || attrs.order_number || ""); + if (!orderId) return { status: 400, body: { error: "Missing order_id" } }; + await exec( + `UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`, + [t, orderId], + ); + return { status: 200, body: { ok: true } }; +} + +async function handleSubscriptionCreated(payload, attrs, t) { + const email = extractEmail(attrs); + const subId = String(payload?.data?.id || attrs.subscription_id || ""); + const variantId = extractVariantId(payload, attrs); + const planCode = planByVariantId(variantId); + if (!email || !subId) { + return { status: 400, body: { error: "Missing email or subscription_id" } }; + } + if (!planCode) { + // Niente mapping -> non sappiamo che limiti applicare. Logghiamo e + // ritorniamo 200 cosi' LS non rispedisce all'infinito. + console.warn("[ls] subscription_created variant non mappato:", variantId, "email=", email); + return { status: 200, body: { ok: true, ignored: "unknown_variant" } }; + } + const plan = getPlan(planCode); + const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at); + const orderId = String(attrs.order_id || ""); + const key = generateLicenseKey(); + + // Idempotenza: se ricevo lo stesso subscription_created due volte, + // riuso la licenza esistente (non ne creo un'altra). + const existing = await one( + "SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1", + [subId], + ); + if (existing) { + await exec( + `UPDATE licenses + SET status='active', plan=?, daily_limit=?, current_period_end=?, updated_at=? + WHERE id=?`, + [planCode, plan.daily_limit, periodEnd, t, existing.id], + ); + return { status: 200, body: { ok: true, duplicate: true } }; + } + + try { + await exec( + `INSERT INTO licenses + (license_key, email, status, plan, daily_limit, + source, order_id, subscription_id, current_period_end, + created_at, updated_at) + VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?, ?)`, + [key, email, planCode, plan.daily_limit, orderId || null, subId, periodEnd, t, t], + ); + } catch (e) { + if (e?.code === "ER_DUP_ENTRY") { + return { status: 200, body: { ok: true, duplicate: true } }; + } + throw e; + } + + try { + await sendLicenseEmail(email, key, { plan: planCode, periodEnd }); + } catch (e) { + console.error("[email] send failed:", e?.message || e); + } + return { status: 200, body: { ok: true, plan: planCode } }; +} + +async function handleSubscriptionUpdated(payload, attrs, t) { + const subId = String(payload?.data?.id || ""); + if (!subId) return { status: 400, body: { error: "Missing subscription_id" } }; + const variantId = extractVariantId(payload, attrs); + const planCode = planByVariantId(variantId); + const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at); + + if (planCode) { + const plan = getPlan(planCode); + await exec( + `UPDATE licenses + SET plan=?, daily_limit=?, current_period_end=?, updated_at=? + WHERE subscription_id=?`, + [planCode, plan.daily_limit, periodEnd, t, subId], + ); + } else if (periodEnd) { + await exec( + `UPDATE licenses SET current_period_end=?, updated_at=? WHERE subscription_id=?`, + [periodEnd, t, subId], + ); + } + return { status: 200, body: { ok: true } }; +} + +async function handleSubscriptionPaymentSuccess(payload, attrs, t) { + // L'evento di pagamento riferisce alla subscription via attrs.subscription_id + const subId = String(attrs.subscription_id || payload?.data?.id || ""); + if (!subId) return { status: 400, body: { error: "Missing subscription_id" } }; + + // Quando un rinnovo va a buon fine LS sposta avanti renews_at. + // Per essere sicuri leggiamo l'evento PIU' recente (subscription_updated + // viene inviato a stretto giro), oppure deduciamo: +30 giorni. + const periodEnd = isoToEpoch(attrs.renews_at) + || isoToEpoch(attrs.created_at) + || (t + 30 * 24 * 3600); + + await exec( + `UPDATE licenses + SET status='active', current_period_end=?, updated_at=? + WHERE subscription_id=?`, + [periodEnd, t, subId], + ); + return { status: 200, body: { ok: true } }; +} + +async function handleSubscriptionExpired(payload, attrs, t) { + const subId = String(payload?.data?.id || attrs.subscription_id || ""); + if (!subId) return { status: 400, body: { error: "Missing subscription_id" } }; + await exec( + `UPDATE licenses SET status='revoked', updated_at=? WHERE subscription_id=?`, + [t, subId], + ); + return { status: 200, body: { ok: true } }; +} + +// ---- Entry point ---------------------------------------------------------- + export async function webhook(req, res) { - // express.raw() salva il body come Buffer in req.body const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : ""; const sig = req.get("X-Signature") || ""; const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET; @@ -39,45 +254,45 @@ export async function webhook(req, res) { const eventName = payload?.meta?.event_name || ""; const attrs = payload?.data?.attributes || {}; - const email = String(attrs.user_email || "").trim().toLowerCase(); - const orderId = String(payload?.data?.id || attrs.order_number || ""); - if (!email || !orderId) { - return res.status(400).json({ error: "Missing email or order_id" }); - } - const t = now(); - if (eventName === "order_created") { - const key = generateLicenseKey(); - try { - await exec( - `INSERT INTO licenses - (license_key, email, status, source, order_id, created_at, updated_at) - VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`, - [key, email, orderId, t, t], - ); - } catch (e) { - // duplicate webhook delivery - if (e?.code === "ER_DUP_ENTRY") { - return res.json({ ok: true, duplicate: true }); - } - throw e; + let out; + try { + switch (eventName) { + case "order_created": + out = await handleOrderCreated(payload, attrs, t); + break; + case "order_refunded": + out = await handleOrderRefunded(payload, attrs, t); + break; + case "subscription_created": + out = await handleSubscriptionCreated(payload, attrs, t); + break; + case "subscription_updated": + case "subscription_resumed": + case "subscription_unpaused": + out = await handleSubscriptionUpdated(payload, attrs, t); + break; + case "subscription_payment_success": + out = await handleSubscriptionPaymentSuccess(payload, attrs, t); + break; + case "subscription_expired": + out = await handleSubscriptionExpired(payload, attrs, t); + break; + case "subscription_cancelled": + case "subscription_paused": + case "subscription_payment_failed": + case "subscription_payment_refunded": + // No-op: il record resta com'e' fino al prossimo evento che cambia stato. + out = { status: 200, body: { ok: true, ignored: eventName } }; + break; + default: + out = { status: 200, body: { ok: true, ignored: eventName } }; } - try { - await sendLicenseEmail(email, key); - } catch (e) { - console.error("[email] send failed:", e?.message || e); - } - return res.json({ ok: true }); + } catch (e) { + console.error("[ls] handler error", eventName, e?.message || e); + return res.status(500).json({ error: "Internal error" }); } - if (eventName === "order_refunded") { - await exec( - `UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`, - [t, orderId], - ); - return res.json({ ok: true }); - } - - res.json({ ok: true, ignored: eventName }); + return res.status(out.status).json(out.body); } diff --git a/server/src/license.js b/server/src/license.js index 4071bd9..4b2212b 100644 --- a/server/src/license.js +++ b/server/src/license.js @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { one, exec } from "./db.js"; import { signJwt, verifyJwt } from "./jwt.js"; +import { getPlan, planSnapshot } from "./plans.js"; const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3); const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30); @@ -9,18 +10,54 @@ const now = () => Math.floor(Date.now() / 1000); const normEmail = (s) => String(s || "").trim().toLowerCase(); const normKey = (s) => String(s || "").trim().toUpperCase(); +/** + * Verifica che una licenza sia ancora "utilizzabile" oggi. + * Ritorna { ok: true } oppure { ok: false, error: "..." }. + * + * Casi gestiti: + * - annual one-time scaduto (expires_at < now) + * - subscription scaduta (current_period_end < now) + */ +function checkLicenseValidity(license, t = now()) { + if (license.status !== "active") { + return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." }; + } + if (license.expires_at && Number(license.expires_at) < t) { + return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." }; + } + if (license.current_period_end && Number(license.current_period_end) < t) { + return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." }; + } + return { ok: true }; +} + async function issueToken(license, deviceId) { const t = now(); - return signJwt({ + const plan = planSnapshot(license.plan); + const claims = { sub: String(license.id), key_id: license.license_key, email: license.email, device_id: deviceId, iat: t, exp: t + TOKEN_TTL_DAYS * 86400, - }, process.env.JWT_SECRET); + }; + if (plan) { + claims.plan = plan.code; + claims.plan_name = plan.name; + claims.daily_limit = plan.daily_limit; + claims.features = plan.features; + claims.is_subscription = plan.is_subscription; + } + if (license.expires_at) claims.expires_at = Number(license.expires_at); + if (license.current_period_end) claims.period_end = Number(license.current_period_end); + return signJwt(claims, process.env.JWT_SECRET); } +const LICENSE_COLUMNS = + "id, license_key, email, status, plan, daily_limit, " + + "subscription_id, current_period_end, expires_at"; + export async function activate(req, res) { const { key, email, device_id, device_name, app_version } = req.body || {}; const K = normKey(key), E = normEmail(email); @@ -30,15 +67,15 @@ export async function activate(req, res) { } const license = await one( - "SELECT id, license_key, email, status FROM licenses WHERE license_key=? AND email=? LIMIT 1", + `SELECT ${LICENSE_COLUMNS} FROM licenses + WHERE license_key=? AND email=? LIMIT 1`, [K, E], ); if (!license) { return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." }); } - if (license.status !== "active") { - return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." }); - } + const check = checkLicenseValidity(license); + if (!check.ok) return res.status(403).json({ error: check.error }); const t = now(); const existing = await one( @@ -71,7 +108,15 @@ export async function activate(req, res) { } const token = await issueToken(license, D); - res.json({ token, activated_at: t, email: license.email }); + const plan = planSnapshot(license.plan); + res.json({ + token, + activated_at: t, + email: license.email, + plan, + expires_at: license.expires_at ? Number(license.expires_at) : null, + period_end: license.current_period_end ? Number(license.current_period_end) : null, + }); } export async function validate(req, res) { @@ -85,12 +130,14 @@ export async function validate(req, res) { if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" }); const license = await one( - "SELECT id, license_key, email, status FROM licenses WHERE id=?", + `SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`, [claims.sub], ); - if (!license || license.status !== "active") { - return res.status(401).json({ error: "Licenza non attiva" }); - } + if (!license) return res.status(401).json({ error: "Licenza non trovata" }); + + const check = checkLicenseValidity(license); + if (!check.ok) return res.status(401).json({ error: check.error }); + const act = await one( "SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?", [license.id, D], @@ -105,7 +152,14 @@ export async function validate(req, res) { ); const fresh = await issueToken(license, D); - res.json({ token: fresh, email: license.email }); + const plan = planSnapshot(license.plan); + res.json({ + token: fresh, + email: license.email, + plan, + expires_at: license.expires_at ? Number(license.expires_at) : null, + period_end: license.current_period_end ? Number(license.current_period_end) : null, + }); } export async function deactivate(req, res) { @@ -126,6 +180,24 @@ export async function deactivate(req, res) { res.json({ ok: true }); } +/** + * Helper interno: dato un token JWT valido, ritorna la riga licenze + * fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se + * il token e' invalido o la licenza non e' piu' attiva. + */ +export async function licenseFromToken(token) { + const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET); + if (!claims) return null; + const lic = await one( + `SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`, + [claims.sub], + ); + if (!lic) return null; + const check = checkLicenseValidity(lic); + if (!check.ok) return null; + return { license: lic, claims }; +} + // Esposto per uso interno (es. webhook genera chiave nuova) export function generateLicenseKey() { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; diff --git a/server/src/plans.js b/server/src/plans.js new file mode 100644 index 0000000..a0204d4 --- /dev/null +++ b/server/src/plans.js @@ -0,0 +1,105 @@ +/** + * Source of truth per i piani MusicTools. + * + * Mappa anche i variant_id di Lemon Squeezy -> plan code, cosi' il + * webhook puo' capire quale piano e' stato comprato senza if/else sparsi. + * + * I variant_id si trovano nel dashboard LS: Products -> click sul prodotto + * -> Variants -> "Edit" -> URL contiene /variants/. Vanno messi in + * .env (LS_VARIANT_*); qui li leggiamo con fallback null. + * + * NOTA su 'features': non e' un set di "tab" ma un set di capability + * lato server. Il client mostra/nasconde le tab in base a queste. + * - "audio" : download brani (tab Brani) + * - "video" : download video YouTube/TikTok/IG/FB (tab Video) + * - "record" : registrazione audio (tab Registra) + * - "metadata" : editor metadati (tab Metadati) + * - "upgrade" : upgrade qualita' libreria (tab Upgrade) + * + * Limiti giornalieri (daily_limit, null = unlimited): contiamo come "uso" + * ogni avvio di un job di download (1 chiamata a /api/usage/consume) E + * ogni salvataggio metadati. La registrazione e' un'azione bound a 1 + * file/giorno: pure 1. + */ + +export const PLANS = { + basic: { + code: "basic", + name: "Basic", + price_eur: 2.99, + interval: "monthly", + daily_limit: 10, + features: ["audio"], + is_subscription: true, + ls_variant_env: "LS_VARIANT_BASIC", + }, + pro: { + code: "pro", + name: "Pro", + price_eur: 5.99, + interval: "monthly", + daily_limit: 30, + features: ["audio", "video", "record", "metadata", "upgrade"], + is_subscription: true, + ls_variant_env: "LS_VARIANT_PRO", + }, + premium: { + code: "premium", + name: "Premium", + price_eur: 9.99, + interval: "monthly", + daily_limit: 150, + features: ["audio", "video", "record", "metadata", "upgrade"], + is_subscription: true, + ls_variant_env: "LS_VARIANT_PREMIUM", + }, + annual: { + code: "annual", + name: "Annual", + price_eur: 49.90, + interval: "annual_one_time", + daily_limit: null, + features: ["audio", "video", "record", "metadata", "upgrade"], + is_subscription: false, + ls_variant_env: "LS_VARIANT_ANNUAL", + }, +}; + +export const PLAN_CODES = Object.keys(PLANS); + +/** Ritorna il plan code mappato a un LS variant_id, o null se non noto. */ +export function planByVariantId(variantId) { + const vid = String(variantId || "").trim(); + if (!vid) return null; + for (const code of PLAN_CODES) { + const envName = PLANS[code].ls_variant_env; + const mapped = String(process.env[envName] || "").trim(); + if (mapped && mapped === vid) return code; + } + return null; +} + +export function getPlan(code) { + return PLANS[code] || null; +} + +/** Forma stabile per inclusione nei JWT claims / risposte API. */ +export function planSnapshot(code) { + const p = getPlan(code); + if (!p) return null; + return { + code: p.code, + name: p.name, + daily_limit: p.daily_limit, + features: p.features.slice(), + is_subscription: p.is_subscription, + }; +} + +export function hasFeature(code, feature) { + const p = getPlan(code); + return !!(p && p.features.includes(feature)); +} + +/** Durata annual one-time in secondi (365 giorni). */ +export const ANNUAL_TTL_SECONDS = 365 * 24 * 3600; diff --git a/server/src/server.js b/server/src/server.js index abeb4a5..79c39c2 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -5,6 +5,7 @@ import * as license from "./license.js"; import * as updates from "./updates.js"; import * as ls from "./lemonsqueezy.js"; import * as dl from "./downloads.js"; +import * as usage from "./usage.js"; const app = express(); @@ -42,6 +43,10 @@ app.post("/api/license/activate", license.activate); app.post("/api/license/validate", license.validate); app.post("/api/license/deactivate", license.deactivate); +// Quota giornaliera +app.get("/api/usage/status", usage.status); +app.post("/api/usage/consume", usage.consume); + // Aggiornamenti + download firmato app.get("/api/latest", updates.latest); app.get("/api/download", updates.download); diff --git a/server/src/usage.js b/server/src/usage.js new file mode 100644 index 0000000..708660d --- /dev/null +++ b/server/src/usage.js @@ -0,0 +1,161 @@ +/** + * Quota giornaliera per piano. + * + * Il client chiama: + * - GET /api/usage/status (Bearer JWT) -> solo lettura, no incremento + * - POST /api/usage/consume (Bearer JWT) -> +1 al contatore, ritorna esito + * + * Il "giorno" e' YYYY-MM-DD nella timezone Europe/Rome. In questo modo + * tutti i clienti hanno lo stesso reset (mezzanotte di Roma) — semplifica + * il marketing ("10 download al giorno, resettati a mezzanotte"). + */ + +import { one, exec } from "./db.js"; +import { licenseFromToken } from "./license.js"; +import { getPlan, planSnapshot } from "./plans.js"; + +const now = () => Math.floor(Date.now() / 1000); + +/** YYYY-MM-DD nella timezone Europe/Rome, partendo da un istante epoch. */ +export function romeDay(epochSeconds = Math.floor(Date.now() / 1000)) { + const d = new Date(epochSeconds * 1000); + // Intl con time zone -> formatta in YYYY-MM-DD senza problemi DST. + const fmt = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Rome", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + // en-CA -> "2026-06-09" + return fmt.format(d); +} + +function bearer(req) { + const auth = req.get("Authorization") || ""; + return auth.startsWith("Bearer ") ? auth.slice(7).trim() : ""; +} + +async function loadAuthorized(req, res) { + const token = bearer(req) || String(req.body?.token || ""); + if (!token) { + res.status(401).json({ error: "Missing token" }); + return null; + } + const auth = await licenseFromToken(token); + if (!auth) { + res.status(401).json({ error: "Licenza non valida o scaduta" }); + return null; + } + return auth; +} + +/** Risposta compatta usata da entrambi gli endpoint. */ +function buildResponse(plan, limit, used) { + const remaining = limit === null ? null : Math.max(0, limit - used); + return { + plan, + used, + limit, // null = illimitato + remaining, // null = illimitato + day: romeDay(), + }; +} + +/** Quanti download ha gia' fatto la licenza oggi (Europe/Rome). */ +async function readUsedToday(licenseId, day) { + const row = await one( + "SELECT count FROM daily_usage WHERE license_id=? AND day=?", + [licenseId, day], + ); + return Number(row?.count || 0); +} + +export async function status(req, res) { + const auth = await loadAuthorized(req, res); + if (!auth) return; + const { license } = auth; + const plan = planSnapshot(license.plan); + const limit = plan?.daily_limit ?? null; + const day = romeDay(); + const used = limit === null ? 0 : await readUsedToday(license.id, day); + res.json(buildResponse(plan, limit, used)); +} + +/** + * Incrementa di 1 il contatore del giorno corrente per la licenza, + * rifiutando con 429 se gia' al limite. Lo facciamo con una UPDATE + * condizionale + INSERT-or-no-op cosi' e' atomico anche sotto carico. + */ +export async function consume(req, res) { + const auth = await loadAuthorized(req, res); + if (!auth) return; + const { license } = auth; + const plan = planSnapshot(license.plan); + if (!plan) return res.status(403).json({ error: "Piano non riconosciuto" }); + + // (Opzionale) il client puo' passare {feature: "video"} per chiarezza, + // ma noi gating-amo gia' lato client e basta avere un piano valido qui. + const feature = String(req.body?.feature || "").trim(); + if (feature && !plan.features.includes(feature)) { + return res.status(403).json({ + error: `Il piano ${plan.name} non include questa funzione.`, + plan, + }); + } + + const limit = plan.daily_limit; + const day = romeDay(); + const t = now(); + + // Unlimited (annual): nessun gate, conta comunque per le statistiche. + if (limit === null) { + await exec( + `INSERT INTO daily_usage (license_id, day, count, updated_at) + VALUES (?, ?, 1, ?) + ON DUPLICATE KEY UPDATE count = count + 1, updated_at = VALUES(updated_at)`, + [license.id, day, t], + ); + const used = await readUsedToday(license.id, day); + return res.json({ ...buildResponse(plan, limit, used), allowed: true }); + } + + // Limited: prova incremento condizionato. Se la riga non esiste, + // INSERT iniziale (count=1). Se esiste e count +1, altrimenti + // affectedRows=0 e ritorniamo 429. + const upd = await exec( + `UPDATE daily_usage + SET count = count + 1, updated_at = ? + WHERE license_id = ? AND day = ? AND count < ?`, + [t, license.id, day, limit], + ); + + if (upd.affectedRows === 0) { + // Due casi: riga non esiste (mai usato oggi) oppure gia' al limite. + const existing = await one( + "SELECT count FROM daily_usage WHERE license_id=? AND day=?", + [license.id, day], + ); + if (!existing) { + try { + await exec( + `INSERT INTO daily_usage (license_id, day, count, updated_at) + VALUES (?, ?, 1, ?)`, + [license.id, day, t], + ); + return res.json({ ...buildResponse(plan, limit, 1), allowed: true }); + } catch (e) { + if (e?.code !== "ER_DUP_ENTRY") throw e; + // race: qualcun altro l'ha inserita -> rileggi + } + } + const used = await readUsedToday(license.id, day); + return res.status(429).json({ + ...buildResponse(plan, limit, used), + allowed: false, + error: `Limite giornaliero raggiunto (${limit}/${limit}). Riprova dopo mezzanotte o passa a un piano superiore.`, + }); + } + + const used = await readUsedToday(license.id, day); + res.json({ ...buildResponse(plan, limit, used), allowed: true }); +} diff --git a/webui/css/style.css b/webui/css/style.css index 52985b4..2daf957 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -194,6 +194,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } margin-top: 2px; } +/* =============================== + QUOTA BOX (sidebar) + =============================== */ +.quota-box { + margin: 10px 8px 0; + padding: 10px 12px; + background: rgba(29, 185, 84, 0.08); + border: 1px solid rgba(29, 185, 84, 0.25); + border-radius: 10px; + text-align: center; +} +.quota-box[hidden] { display: none; } +.quota-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 1px; + color: var(--text-3); + text-transform: uppercase; +} +.quota-value { + font-size: 22px; + font-weight: 800; + color: var(--text); + margin: 2px 0; +} +.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; } +.quota-plan { + font-size: 11px; + color: var(--green); + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; +} +.quota-upgrade { + margin-top: 8px; + background: var(--green); + border: none; + color: #000; + font-weight: 700; + font-size: 11px; + padding: 6px 12px; + border-radius: 999px; + cursor: pointer; + width: 100%; +} +.quota-upgrade[hidden] { display: none; } +.quota-upgrade:hover { filter: brightness(1.1); } + +/* Modal Upgrade — testo prose, non monospace */ +#upgradeModal .modal-body { + font-family: inherit; + font-size: 14px; + color: var(--text); + white-space: normal; +} +#upgradeModal .modal-body p { margin: 0 0 10px; line-height: 1.5; } +#upgradeModal .modal-body .modal-hint { + color: var(--text-2); + font-size: 13px; +} + /* =============================== MAIN =============================== */ diff --git a/webui/index.html b/webui/index.html index d4536c7..0da39e5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -21,23 +21,23 @@
+ +
+ + +
diff --git a/webui/js/app.js b/webui/js/app.js index 440abed..de6ae2b 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -80,7 +80,24 @@ function renderLicenseStatus() { box.innerHTML = `
Licenza non attiva
`; return; } + const plan = lic.plan || {}; + const planLine = plan.code + ? `
Piano${plan.name || plan.code}
` + : ""; + const limitLine = plan.daily_limit + ? `
Limite giornaliero${plan.daily_limit}
` + : (plan.code === "annual" + ? `
Limite giornalieroIllimitato
` + : ""); + const expiryLine = plan.expires_at + ? `
Scade il${fmtDate(plan.expires_at)}
` + : (plan.period_end + ? `
Prossimo rinnovo${fmtDate(plan.period_end)}
` + : ""); box.innerHTML = ` + ${planLine} + ${limitLine} + ${expiryLine}
Email${lic.email || "—"}
Chiave${lic.key || "—"}
Attivata il${fmtDate(lic.activated_at)}
@@ -88,6 +105,109 @@ function renderLicenseStatus() { `; } +// ============================================================ +// Piano e quota giornaliera +// ============================================================ +function applyPlanGate() { + // Nasconde le tab non incluse nel piano corrente. + const lic = state.license || {}; + const features = (lic.plan && lic.plan.features) || []; + $$(".nav-item[data-feature]").forEach((btn) => { + const f = btn.dataset.feature; + const allowed = features.includes(f); + btn.hidden = !allowed; + if (!allowed && btn.classList.contains("active")) { + // Fallback alla prima tab disponibile (audio o settings) + const fallback = $$(".nav-item[data-feature]:not([hidden])")[0] + || $(".nav-item[data-view='settings']"); + if (fallback) showView(fallback.dataset.view); + } + }); +} + +function renderQuotaBox(q) { + const box = $("#quotaBox"); + if (!box) return; + const lic = state.license || {}; + const plan = (q && q.plan) || lic.plan || {}; + if (!plan.code || plan.code === "annual" || plan.daily_limit == null) { + // Annual / unlimited -> niente contatore + box.hidden = true; + return; + } + box.hidden = false; + $("#quotaUsed").textContent = String((q && q.used) ?? 0); + $("#quotaLimit").textContent = String(plan.daily_limit); + $("#quotaPlanName").textContent = plan.name || plan.code; + const upBtn = $("#quotaUpgradeBtn"); + if (upBtn) upBtn.hidden = plan.code === "premium"; +} + +async function refreshQuota() { + const lic = state.license || {}; + const plan = lic.plan || {}; + if (!lic.licensed || !plan.code || plan.daily_limit == null) { + renderQuotaBox(null); + return; + } + try { + const res = await window.pywebview.api.get_quota_status(); + if (res && res.ok) { + renderQuotaBox(res.quota); + } + } catch (_e) { /* offline: lascia stato precedente */ } +} + +function showUpgradeModal(reason, payload) { + const modal = $("#upgradeModal"); + if (!modal) return; + const title = $("#upgradeModalTitle"); + const body = $("#upgradeModalBody"); + if (reason === "feature_not_in_plan") { + title.textContent = "Funzione non inclusa nel piano"; + body.innerHTML = ` +

${payload.error || "Questa funzione non e' inclusa nel tuo piano."}

+ + `; + } else if (reason === "quota_exceeded") { + const q = payload.quota || {}; + title.textContent = "Limite giornaliero raggiunto"; + body.innerHTML = ` +

${payload.error || "Hai raggiunto il limite giornaliero del tuo piano."}

+ + `; + } else if (reason === "license_invalid") { + title.textContent = "Licenza non valida"; + body.innerHTML = `

${payload.error || "La tua licenza non e' piu' valida."}

`; + } else if (reason === "offline") { + title.textContent = "Connessione assente"; + body.innerHTML = `

${payload.error || "Impossibile contattare il server."}

`; + } else { + title.textContent = "Operazione bloccata"; + body.innerHTML = `

${payload.error || "L'operazione non e' stata avviata."}

`; + } + modal.hidden = false; +} + +function hideUpgradeModal() { + const modal = $("#upgradeModal"); + if (modal) modal.hidden = true; +} + +/** Gestisce il dict di errore restituito dai metodi start_*: se e' un + * gate failure (feature/quota/license/offline), mostra il modal e + * ritorna true (= caller deve interrompere il flow). */ +function handleGateBlock(res) { + if (!res || res.ok !== false) return false; + const r = res.reason; + if (r === "feature_not_in_plan" || r === "quota_exceeded" + || r === "license_invalid" || r === "offline") { + showUpgradeModal(r, res); + return true; + } + return false; +} + async function activateLicense() { const email = $("#actEmail").value.trim(); const key = $("#actKey").value.trim(); @@ -109,6 +229,8 @@ async function activateLicense() { state.license = res.license; applyLicenseGate(); renderLicenseStatus(); + applyPlanGate(); + refreshQuota(); toast("Licenza attivata. Benvenuto!", "success"); } else { err.textContent = res.error || "Errore di attivazione."; @@ -143,6 +265,7 @@ window.bridge = { const bridgeHandlers = { "log": ({ view, msg }) => appendLog(view, msg), + "quota:update": (q) => renderQuotaBox(q), "download:progress": (p) => { if (typeof p.overall === "number") { @@ -336,6 +459,8 @@ async function init() { applyLicenseGate(); renderLicenseStatus(); + applyPlanGate(); + refreshQuota(); // Populate Settings fields $("#clientIdInput").value = state.config.client_id || ""; @@ -471,7 +596,7 @@ $("#downloadBtn").addEventListener("click", async () => { subfolder: "", }); if (!res.ok) { - toast(res.error || "Errore", "error"); + if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); bridgeHandlers["download:done"](); } return; @@ -484,7 +609,7 @@ $("#downloadBtn").addEventListener("click", async () => { } if (!res.ok) { - toast(res.error || "Errore", "error"); + if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); bridgeHandlers["download:done"](); } }); @@ -541,7 +666,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => { audio_only: videoMode === "audio", }); if (!res.ok) { - toast(res.error || "Errore", "error"); + if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); bridgeHandlers["video:done"](); } }); @@ -602,7 +727,7 @@ $("#upgradeBtn").addEventListener("click", async () => { threshold: parseInt($("#upThreshold").value, 10) || 310, }); if (!res.ok) { - toast(res.error || "Errore", "error"); + if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); bridgeHandlers["upgrade:done"](); } }); @@ -694,7 +819,7 @@ $("#recStartBtn").addEventListener("click", async () => { bitrate: $("#recBitrate").value, }); if (!res.ok) { - toast(res.error || "Errore", "error"); + if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); } }); @@ -876,7 +1001,7 @@ $("#metaSaveBtn").addEventListener("click", async () => { await loadMetaFile(state.meta.path); } else { status.textContent = ""; - toast("Errore: " + (res.error || ""), "error"); + if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error"); } }); @@ -1003,6 +1128,8 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => { if (res.ok) { state.license = res.license; renderLicenseStatus(); + applyPlanGate(); + refreshQuota(); if (res.license.licensed) { toast("Licenza verificata", "success"); } else { @@ -1016,6 +1143,22 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => { } }); +// ============================================================ +// Modal Upgrade +// ============================================================ +$("#upgradeCloseBtn")?.addEventListener("click", hideUpgradeModal); +$("#upgradeCloseBtn2")?.addEventListener("click", hideUpgradeModal); +$("#upgradeModal")?.addEventListener("click", (e) => { + if (e.target === e.currentTarget) hideUpgradeModal(); +}); +$("#upgradeOpenBtn")?.addEventListener("click", async () => { + hideUpgradeModal(); + try { await window.pywebview.api.open_purchase_page(); } catch (_e) {} +}); +$("#quotaUpgradeBtn")?.addEventListener("click", async () => { + try { await window.pywebview.api.open_purchase_page(); } catch (_e) {} +}); + // ============================================================ // Boot // ============================================================