Files
musicdownload/server/src/license.js
T
luzadev 61459099f9 v1.7.0: piani Basic/Pro/Premium/Annual + quota giornaliera
Pricing model: subscription mensili (Basic 2.99 / Pro 5.99 / Premium 9.99)
piu' Annual 49.90 one-time. Feature gating per piano (Basic = solo audio,
Pro+ = tutto). Quota giornaliera (10/30/150) con reset mezzanotte Europe/Rome.

Server: tabella daily_usage, endpoint /api/usage/consume e /status,
webhook LS gestisce subscription_* events + mappatura variant_id -> plan
via .env (LS_VARIANT_BASIC|PRO|PREMIUM|ANNUAL).

Client: gating tab in sidebar in base ai features del piano, contatore
"X/Y oggi" + bottone Upgrade, modal blocco con CTA verso landing.
2026-06-09 11:02:42 +02:00

209 lines
7.2 KiB
JavaScript

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);
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();
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,
};
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);
const D = String(device_id || "").trim();
if (!K || !E || !D) {
return res.status(400).json({ error: "Missing key, email or device_id" });
}
const license = await one(
`SELECT ${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." });
}
const check = checkLicenseValidity(license);
if (!check.ok) return res.status(403).json({ error: check.error });
const t = now();
const existing = await one(
"SELECT id FROM activations WHERE license_id=? AND device_id=? LIMIT 1",
[license.id, D],
);
if (existing) {
await exec(
`UPDATE activations SET app_version=?, device_name=?, last_seen_at=?, revoked_at=NULL
WHERE id=?`,
[app_version || null, device_name || null, t, existing.id],
);
} else {
const row = await one(
"SELECT COUNT(*) AS n FROM activations WHERE license_id=? AND revoked_at IS NULL",
[license.id],
);
if ((row?.n || 0) >= MAX_ACTIVATIONS) {
return res.status(409).json({
error: `Hai gia attivato la licenza su ${MAX_ACTIVATIONS} dispositivi. Disattivane uno per usarla qui.`,
});
}
await exec(
`INSERT INTO activations
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[license.id, D, device_name || null, app_version || null, t, t],
);
}
const token = await issueToken(license, D);
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) {
const { token, device_id, app_version } = req.body || {};
const T = String(token || "").trim();
const D = String(device_id || "").trim();
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
const claims = verifyJwt(T, process.env.JWT_SECRET);
if (!claims) return res.status(401).json({ error: "Token invalido o scaduto" });
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
const license = await one(
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
[claims.sub],
);
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],
);
if (!act || act.revoked_at !== null) {
return res.status(401).json({ error: "Attivazione non trovata o revocata" });
}
await exec(
"UPDATE activations SET last_seen_at=?, app_version=? WHERE id=?",
[now(), app_version || null, act.id],
);
const fresh = await issueToken(license, D);
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) {
const { token, device_id } = req.body || {};
const T = String(token || "").trim();
const D = String(device_id || "").trim();
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
const claims = verifyJwt(T, process.env.JWT_SECRET);
if (!claims) return res.status(401).json({ error: "Token invalido" });
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
await exec(
`UPDATE activations SET revoked_at=?
WHERE license_id=? AND device_id=? AND revoked_at IS NULL`,
[now(), claims.sub, D],
);
res.json({ ok: true });
}
/**
* 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";
const buf = crypto.randomBytes(16);
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
return [chars.slice(0,4), chars.slice(4,8), chars.slice(8,12), chars.slice(12,16)]
.map((g) => g.join("")).join("-");
}