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).
338 lines
12 KiB
JavaScript
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);
|
|
}
|