Files
musicdownload/server/src/lemonsqueezy.js
T
luzadev 6e96e8c6b7 v1.7.8: sticky save button + Spotify Premium chiarito
Impostazioni: il bottone "Salva impostazioni" e' ora sticky in fondo
al viewport, sempre visibile senza dover scrollare in coda alla pagina.

Spotify: aggiornata guida in-app e landing per riflettere il nuovo
requisito Premium per creare le API keys (fino a oggi era gratis con
account Spotify Free; Spotify ha cambiato policy). Singoli brani e
tutte le altre fonti continuano a funzionare senza Premium.

Inclusi anche file gia deployati lato server in sessioni precedenti
(webhook LS, landing icone PNG, download.html Gatekeeper instructions).
2026-06-09 15:42:42 +02:00

338 lines
12 KiB
JavaScript

/**
* Webhook Lemon Squeezy.
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
*
* 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);
function hmacHex(secret, data) {
return crypto.createHmac("sha256", secret).update(data).digest("hex");
}
function timingSafe(a, b) {
if (a.length !== b.length) return false;
const A = Buffer.from(a), B = Buffer.from(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;
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
// non duplichiamo licenza ne' email — ack 200 e basta.
const existing = await one(
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
[orderId],
);
if (existing) {
return { status: 200, body: { ok: true, duplicate: true } };
}
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
// i limiti esatti del piano comprato).
const planForRecord = plan?.code || "annual";
const planMeta = plan || getPlan("annual");
const limit = planMeta?.daily_limit ?? null;
// Annual one-time: settiamo expires_at = now + 365d.
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
// subscription_created/subscription_payment_success quando arriva.
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
// dashboard), la licenza resta utilizzabile (current_period_end NULL
// = no scadenza imposta lato server: la sottoscrizione vivra' o
// morira' su LS, e i webhook successivi aggiorneranno lo status).
const expiresAt = planMeta?.is_subscription ? null : 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: 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.
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 } };
}
// Caso normale ora: la licenza e' gia stata creata da order_created
// (con subscription_id NULL). La aggiorno con subscription_id e
// current_period_end senza generare una nuova chiave ne' una nuova email.
const byOrder = orderId
? await one(
`SELECT id FROM licenses
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
[orderId],
)
: null;
if (byOrder) {
await exec(
`UPDATE licenses
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
updated_at=?
WHERE id=?`,
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
);
return { status: 200, body: { ok: true, attached_subscription: subId } };
}
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
// (puo' succedere se order_created non e' arrivato per qualche motivo).
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) {
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
const sig = req.get("X-Signature") || "";
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
if (!sig || !secret) return res.status(400).json({ error: "Missing signature" });
const expected = hmacHex(secret, raw);
if (!timingSafe(sig, expected)) {
return res.status(401).json({ error: "Invalid signature" });
}
let payload;
try { payload = JSON.parse(raw); }
catch { return res.status(400).json({ error: "Invalid JSON" }); }
const eventName = payload?.meta?.event_name || "";
const attrs = payload?.data?.attributes || {};
const t = now();
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 } };
}
} catch (e) {
console.error("[ls] handler error", eventName, e?.message || e);
return res.status(500).json({ error: "Internal error" });
}
return res.status(out.status).json(out.body);
}