Riscrivi backend per hosting Node/MariaDB/PM2 (no Cloudflare)
L'utente ha un hosting Virtualmin esistente (musictools.djluza.com)
con Apache + Node 20 + MariaDB + PM2. Tutta l'infrastruttura
Cloudflare (Workers/D1/R2/Pages) e' rimpiazzata con un backend
Express che gira sul server gia' presente, a costo zero.
Cambia:
- server/src/*.ts (Workers) -> server/src/*.js (Node ESM puro)
- D1 (sqlite) -> MariaDB 10.11 via mysql2/promise
- R2 (storage) -> filesystem locale ~/builds/ + signed URL HMAC
- wrangler.toml -> ecosystem.config.cjs (PM2)
- Aggiunto src/migrate.js: runner SQL idempotente
Endpoints invariati - l'app desktop non vede differenze:
- POST /api/license/{activate,validate,deactivate}
- GET /api/latest, /api/download
- POST /api/webhook/lemonsqueezy
- GET /api/health
README riscritto con istruzioni complete: creazione DB,
deploy via rsync/git, config Apache reverse proxy via
Virtualmin, gestione PM2, backup, workflow release.
Email rimane su Resend (deliverability) - free tier sufficiente.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
339dd4f3cc
commit
2d8290579b
22 files changed
+892
-790
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import mysql from "mysql2/promise";
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || "127.0.0.1",
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
charset: "utf8mb4",
|
||||
});
|
||||
|
||||
export async function query(sql, params = []) {
|
||||
const [rows] = await pool.execute(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function one(sql, params = []) {
|
||||
const rows = await query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function exec(sql, params = []) {
|
||||
const [result] = await pool.execute(sql, params);
|
||||
return result; // { insertId, affectedRows, ... }
|
||||
}
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Invio email transazionali via Resend (https://resend.com).
|
||||
* Usa fetch nativo di Node >= 20 — nessuna dipendenza.
|
||||
*
|
||||
* Variabili env richieste:
|
||||
* RESEND_API_KEY dal dashboard Resend
|
||||
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
|
||||
*/
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
||||
|
||||
export async function sendLicenseEmail(to, licenseKey) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `
|
||||
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
||||
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
|
||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||
</p>
|
||||
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
|
||||
${licenseKey}
|
||||
</p>
|
||||
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
|
||||
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
|
||||
<li>Scarica MusicTools per <a href="https://musictools.djluza.com#pricing">Mac o Windows</a></li>
|
||||
<li>Apri l'app: alla prima schermata ti chiedera' email e chiave</li>
|
||||
<li>Inserisci questa email (<code>${to}</code>) e la chiave qui sopra</li>
|
||||
</ol>
|
||||
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
|
||||
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const resp = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: FROM,
|
||||
to: [to],
|
||||
subject: "La tua licenza MusicTools",
|
||||
html,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
throw new Error(`Resend ${resp.status}: ${txt}`);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
export function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function notFound(): Response {
|
||||
return json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
export function methodNotAllowed(): Response {
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
export function badRequest(msg: string): Response {
|
||||
return json({ error: msg }, 400);
|
||||
}
|
||||
|
||||
export function unauthorized(msg = "Unauthorized"): Response {
|
||||
return json({ error: msg }, 401);
|
||||
}
|
||||
|
||||
export async function readJson<T = any>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new Response(JSON.stringify({ error: "Invalid JSON" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function now(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* JWT HS256 minimale (niente dipendenze). Stessa logica del worker
|
||||
* Cloudflare precedente: stesso token formato, stessa firma, stessa
|
||||
* verifica. L'app desktop non distingue.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString("base64")
|
||||
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function b64urlDecode(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
s += "=".repeat((4 - (s.length % 4)) % 4);
|
||||
return Buffer.from(s, "base64");
|
||||
}
|
||||
|
||||
function hmac(secret, data) {
|
||||
return crypto.createHmac("sha256", secret).update(data).digest();
|
||||
}
|
||||
|
||||
export function signJwt(claims, secret) {
|
||||
const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const body = b64url(JSON.stringify(claims));
|
||||
const sig = b64url(hmac(secret, `${head}.${body}`));
|
||||
return `${head}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token, secret) {
|
||||
if (typeof token !== "string") return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [head, body, sig] = parts;
|
||||
const expected = b64url(hmac(secret, `${head}.${body}`));
|
||||
// confronto a tempo costante
|
||||
if (sig.length !== expected.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
if (diff !== 0) return null;
|
||||
try {
|
||||
const claims = JSON.parse(b64urlDecode(body).toString("utf-8"));
|
||||
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Minimal JWT HS256 implementation using Web Crypto (available in Workers).
|
||||
* We don't pull in a library to keep the worker bundle tiny.
|
||||
*/
|
||||
|
||||
function b64url(buf: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Uint8Array {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
s += "=".repeat((4 - (s.length % 4)) % 4);
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function hmac(secret: string, data: string): Promise<ArrayBuffer> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
return crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
||||
}
|
||||
|
||||
export interface JwtClaims {
|
||||
sub: string; // license_id
|
||||
key_id: string; // license_key (mascherata o intera)
|
||||
email: string;
|
||||
device_id: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export async function signJwt(claims: JwtClaims, secret: string): Promise<string> {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const head = b64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||
const body = b64url(new TextEncoder().encode(JSON.stringify(claims)));
|
||||
const sig = b64url(await hmac(secret, `${head}.${body}`));
|
||||
return `${head}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
export async function verifyJwt(token: string, secret: string): Promise<JwtClaims | null> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [head, body, sig] = parts;
|
||||
const expected = b64url(await hmac(secret, `${head}.${body}`));
|
||||
if (expected !== sig) return null;
|
||||
try {
|
||||
const claims = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as JwtClaims;
|
||||
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Webhook Lemon Squeezy.
|
||||
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||
* Eventi gestiti: order_created, order_refunded.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { generateLicenseKey } from "./license.js";
|
||||
import { sendLicenseEmail } from "./email.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);
|
||||
}
|
||||
|
||||
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;
|
||||
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 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;
|
||||
}
|
||||
try {
|
||||
await sendLicenseEmail(email, key);
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Webhook Lemon Squeezy.
|
||||
*
|
||||
* Configurazione:
|
||||
* - Crea il webhook dal dashboard LS (My Store > Settings > Webhooks)
|
||||
* - URL: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||
* - Eventi: order_created, subscription_payment_success (per future estensioni),
|
||||
* order_refunded
|
||||
* - Secret: salvalo come "LEMONSQUEEZY_SIGNING_SECRET" (wrangler secret put)
|
||||
*
|
||||
* Flusso order_created:
|
||||
* 1. Verifica firma X-Signature == HMAC-SHA256(secret, raw_body)
|
||||
* 2. Estrai email cliente + order_id
|
||||
* 3. Genera license_key (XXXX-XXXX-XXXX-XXXX), insert in 'licenses'
|
||||
* 4. Invia email all'utente via Resend con la chiave
|
||||
*/
|
||||
|
||||
import { json, now } from "./http";
|
||||
import type { Env } from "./worker";
|
||||
|
||||
interface LSPayload {
|
||||
meta?: { event_name?: string; custom_data?: Record<string, unknown> };
|
||||
data?: {
|
||||
id?: string;
|
||||
type?: string;
|
||||
attributes?: {
|
||||
user_email?: string;
|
||||
order_number?: number | string;
|
||||
refunded?: boolean;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleLemonSqueezyWebhook(req: Request, env: Env): Promise<Response> {
|
||||
const raw = await req.text();
|
||||
const sig = req.headers.get("X-Signature") || "";
|
||||
if (!sig || !env.LEMONSQUEEZY_SIGNING_SECRET) {
|
||||
return json({ error: "Missing signature" }, 400);
|
||||
}
|
||||
|
||||
const expected = await hmacHex(env.LEMONSQUEEZY_SIGNING_SECRET, raw);
|
||||
if (!timingSafeEqual(sig, expected)) {
|
||||
return json({ error: "Invalid signature" }, 401);
|
||||
}
|
||||
|
||||
let payload: LSPayload;
|
||||
try {
|
||||
payload = JSON.parse(raw) as LSPayload;
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const eventName = payload.meta?.event_name || "";
|
||||
const attrs = payload.data?.attributes || {};
|
||||
const email = (attrs.user_email || "").trim().toLowerCase();
|
||||
const orderId = String(payload.data?.id || attrs.order_number || "");
|
||||
|
||||
if (!email || !orderId) {
|
||||
return json({ error: "Missing email or order_id" }, 400);
|
||||
}
|
||||
|
||||
const t = now();
|
||||
|
||||
if (eventName === "order_created") {
|
||||
const key = generateLicenseKey();
|
||||
try {
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO licenses (license_key, email, status, source, order_id, created_at, updated_at)
|
||||
VALUES (?1, ?2, 'active', 'lemonsqueezy', ?3, ?4, ?4)`
|
||||
).bind(key, email, orderId, t).run();
|
||||
} catch (e) {
|
||||
// unique violation (webhook duplicato): no-op
|
||||
console.warn("Insert license failed (probabile duplicato):", e);
|
||||
return json({ ok: true, duplicate: true });
|
||||
}
|
||||
await sendLicenseEmail(env, email, key);
|
||||
return json({ ok: true, license_key_masked: key.slice(0, 4) + "..." });
|
||||
}
|
||||
|
||||
if (eventName === "order_refunded") {
|
||||
await env.DB.prepare(
|
||||
`UPDATE licenses SET status='refunded', updated_at=?1
|
||||
WHERE order_id=?2`
|
||||
).bind(t, orderId).run();
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
return json({ ok: true, ignored: eventName });
|
||||
}
|
||||
|
||||
function generateLicenseKey(): string {
|
||||
// 16 caratteri base32 (no I/O/0/1 ambigui), in 4 gruppi da 4.
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
|
||||
return [chars.slice(0, 4), chars.slice(4, 8), chars.slice(8, 12), chars.slice(12, 16)]
|
||||
.map((g) => g.join("")).join("-");
|
||||
}
|
||||
|
||||
async function hmacHex(secret: string, data: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw", new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
||||
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
async function sendLicenseEmail(env: Env, email: string, key: string): Promise<void> {
|
||||
if (!env.RESEND_API_KEY) {
|
||||
console.warn("RESEND_API_KEY non impostata, skip invio email");
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
from: "MusicTools <noreply@djluza.com>",
|
||||
to: [email],
|
||||
subject: "La tua licenza MusicTools",
|
||||
html: `
|
||||
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
||||
<h1 style="color:#1db954">Grazie per aver scelto MusicTools!</h1>
|
||||
<p>Ecco la tua chiave di licenza:</p>
|
||||
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:14px;border-radius:8px;text-align:center">
|
||||
${key}
|
||||
</p>
|
||||
<p>Per attivarla:</p>
|
||||
<ol>
|
||||
<li>Scarica MusicTools per <a href="https://musictools.djluza.com/download/macos">macOS</a> o <a href="https://musictools.djluza.com/download/windows">Windows</a></li>
|
||||
<li>Apri l'app: ti chiedera' email e chiave</li>
|
||||
<li>Inserisci questa email (<code>${email}</code>) e la chiave qui sopra</li>
|
||||
</ol>
|
||||
<p>Puoi attivare la licenza fino a 3 dispositivi.</p>
|
||||
<hr/>
|
||||
<p style="color:#666;font-size:12px">Hai problemi? Scrivici a info@djluza.com</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
const resp = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.error("Resend error:", await resp.text());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { signJwt, verifyJwt } from "./jwt.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();
|
||||
|
||||
async function issueToken(license, deviceId) {
|
||||
const t = now();
|
||||
return signJwt({
|
||||
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);
|
||||
}
|
||||
|
||||
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 id, license_key, email, status 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 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);
|
||||
res.json({ token, activated_at: t, email: license.email });
|
||||
}
|
||||
|
||||
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 id, license_key, email, status FROM licenses WHERE id=?",
|
||||
[claims.sub],
|
||||
);
|
||||
if (!license || license.status !== "active") {
|
||||
return res.status(401).json({ error: "Licenza non attiva" });
|
||||
}
|
||||
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);
|
||||
res.json({ token: fresh, email: license.email });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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("-");
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { json, badRequest, unauthorized, readJson, now } from "./http";
|
||||
import { signJwt, verifyJwt } from "./jwt";
|
||||
import type { Env } from "./worker";
|
||||
|
||||
interface LicenseRow {
|
||||
id: number;
|
||||
license_key: string;
|
||||
email: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface ActivationRow {
|
||||
id: number;
|
||||
license_id: number;
|
||||
device_id: string;
|
||||
device_name: string | null;
|
||||
app_version: string | null;
|
||||
activated_at: number;
|
||||
last_seen_at: number;
|
||||
revoked_at: number | null;
|
||||
}
|
||||
|
||||
function normalizeEmail(s: string): string {
|
||||
return (s || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeKey(s: string): string {
|
||||
return (s || "").trim().toUpperCase();
|
||||
}
|
||||
|
||||
async function getLicense(env: Env, key: string, email: string): Promise<LicenseRow | null> {
|
||||
const stmt = env.DB.prepare(
|
||||
`SELECT id, license_key, email, status FROM licenses
|
||||
WHERE license_key = ?1 AND email = ?2 LIMIT 1`
|
||||
).bind(key, email);
|
||||
return await stmt.first<LicenseRow>();
|
||||
}
|
||||
|
||||
async function countActiveActivations(env: Env, licenseId: number): Promise<number> {
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM activations
|
||||
WHERE license_id = ?1 AND revoked_at IS NULL`
|
||||
).bind(licenseId).first<{ n: number }>();
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
|
||||
async function findActivation(env: Env, licenseId: number, deviceId: string): Promise<ActivationRow | null> {
|
||||
return await env.DB.prepare(
|
||||
`SELECT * FROM activations
|
||||
WHERE license_id = ?1 AND device_id = ?2 LIMIT 1`
|
||||
).bind(licenseId, deviceId).first<ActivationRow>();
|
||||
}
|
||||
|
||||
async function issueToken(env: Env, license: LicenseRow, deviceId: string): Promise<string> {
|
||||
const ttlDays = parseInt(env.TOKEN_TTL_DAYS || "30", 10);
|
||||
const t = now();
|
||||
return signJwt({
|
||||
sub: String(license.id),
|
||||
key_id: license.license_key,
|
||||
email: license.email,
|
||||
device_id: deviceId,
|
||||
iat: t,
|
||||
exp: t + ttlDays * 86400,
|
||||
}, env.JWT_SECRET);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/license/activate
|
||||
// ============================================================
|
||||
export async function handleActivate(req: Request, env: Env): Promise<Response> {
|
||||
const body = await readJson<{
|
||||
key?: string; email?: string; device_id?: string;
|
||||
device_name?: string; app_version?: string;
|
||||
}>(req);
|
||||
|
||||
const key = normalizeKey(body.key || "");
|
||||
const email = normalizeEmail(body.email || "");
|
||||
const deviceId = (body.device_id || "").trim();
|
||||
if (!key || !email || !deviceId) {
|
||||
return badRequest("Missing key, email or device_id");
|
||||
}
|
||||
|
||||
const license = await getLicense(env, key, email);
|
||||
if (!license) {
|
||||
return json({ error: "Chiave o email non corrispondono a un acquisto." }, 404);
|
||||
}
|
||||
if (license.status !== "active") {
|
||||
return json({ error: "Licenza non piu' valida (rimborsata o revocata)." }, 403);
|
||||
}
|
||||
|
||||
const max = parseInt(env.MAX_ACTIVATIONS || "3", 10);
|
||||
const t = now();
|
||||
|
||||
const existing = await findActivation(env, license.id, deviceId);
|
||||
if (existing) {
|
||||
// Re-attivazione sullo stesso device: aggiorna last_seen.
|
||||
await env.DB.prepare(
|
||||
`UPDATE activations
|
||||
SET app_version=?1, device_name=?2, last_seen_at=?3, revoked_at=NULL
|
||||
WHERE id=?4`
|
||||
).bind(body.app_version || null, body.device_name || null, t, existing.id).run();
|
||||
} else {
|
||||
const active = await countActiveActivations(env, license.id);
|
||||
if (active >= max) {
|
||||
return json({
|
||||
error: `Hai gia attivato la licenza su ${max} dispositivi. Disattivane uno per usarla qui.`,
|
||||
}, 409);
|
||||
}
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO activations
|
||||
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
|
||||
).bind(license.id, deviceId, body.device_name || null, body.app_version || null, t).run();
|
||||
}
|
||||
|
||||
const token = await issueToken(env, license, deviceId);
|
||||
return json({
|
||||
token,
|
||||
activated_at: t,
|
||||
email: license.email,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/license/validate
|
||||
// ============================================================
|
||||
export async function handleValidate(req: Request, env: Env): Promise<Response> {
|
||||
const body = await readJson<{ token?: string; device_id?: string; app_version?: string }>(req);
|
||||
const token = (body.token || "").trim();
|
||||
const deviceId = (body.device_id || "").trim();
|
||||
if (!token || !deviceId) return badRequest("Missing token or device_id");
|
||||
|
||||
const claims = await verifyJwt(token, env.JWT_SECRET);
|
||||
if (!claims) return unauthorized("Token invalido o scaduto");
|
||||
if (claims.device_id !== deviceId) {
|
||||
return unauthorized("device_id mismatch");
|
||||
}
|
||||
|
||||
const license = await env.DB.prepare(
|
||||
`SELECT id, license_key, email, status FROM licenses WHERE id = ?1`
|
||||
).bind(claims.sub).first<LicenseRow>();
|
||||
if (!license || license.status !== "active") {
|
||||
return unauthorized("Licenza non attiva");
|
||||
}
|
||||
|
||||
const act = await findActivation(env, license.id, deviceId);
|
||||
if (!act || act.revoked_at !== null) {
|
||||
return unauthorized("Attivazione non trovata o revocata");
|
||||
}
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE activations SET last_seen_at=?1, app_version=?2 WHERE id=?3`
|
||||
).bind(now(), body.app_version || act.app_version, act.id).run();
|
||||
|
||||
// Rotazione token: ne emettiamo uno nuovo per estendere l'exp
|
||||
const fresh = await issueToken(env, license, deviceId);
|
||||
return json({ token: fresh, email: license.email });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// POST /api/license/deactivate
|
||||
// ============================================================
|
||||
export async function handleDeactivate(req: Request, env: Env): Promise<Response> {
|
||||
const body = await readJson<{ token?: string; device_id?: string }>(req);
|
||||
const token = (body.token || "").trim();
|
||||
const deviceId = (body.device_id || "").trim();
|
||||
if (!token || !deviceId) return badRequest("Missing token or device_id");
|
||||
|
||||
const claims = await verifyJwt(token, env.JWT_SECRET);
|
||||
if (!claims) return unauthorized("Token invalido");
|
||||
if (claims.device_id !== deviceId) return unauthorized("device_id mismatch");
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE activations SET revoked_at=?1
|
||||
WHERE license_id=?2 AND device_id=?3 AND revoked_at IS NULL`
|
||||
).bind(now(), claims.sub, deviceId).run();
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Applica i file SQL in migrations/ in ordine alfabetico.
|
||||
* Tiene traccia dei file gia eseguiti in una tabella `schema_migrations`.
|
||||
*
|
||||
* Run: npm run migrate
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pool, { query, exec } from "./db.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MIG_DIR = path.resolve(__dirname, "../migrations");
|
||||
|
||||
async function ensureMigrationsTable() {
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
applied_at BIGINT UNSIGNED NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await ensureMigrationsTable();
|
||||
const applied = new Set(
|
||||
(await query("SELECT filename FROM schema_migrations")).map((r) => r.filename),
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(MIG_DIR)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
for (const f of files) {
|
||||
if (applied.has(f)) {
|
||||
console.log(`[migrate] skip ${f} (gia applicato)`);
|
||||
continue;
|
||||
}
|
||||
const sql = fs.readFileSync(path.join(MIG_DIR, f), "utf-8");
|
||||
console.log(`[migrate] applico ${f}`);
|
||||
// mysql2 supporta multipleStatements ma e' rischioso; splittiamo manualmente.
|
||||
const statements = sql
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && !s.startsWith("--"));
|
||||
for (const stmt of statements) {
|
||||
await exec(stmt);
|
||||
}
|
||||
await exec(
|
||||
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
|
||||
[f, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
console.log(`[migrate] ok ${f}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log("[migrate] done");
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error("[migrate] errore:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
|
||||
import * as license from "./license.js";
|
||||
import * as updates from "./updates.js";
|
||||
import * as ls from "./lemonsqueezy.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// L'app sta dietro Apache reverse proxy: fidati di X-Forwarded-* dal localhost.
|
||||
app.set("trust proxy", "loopback");
|
||||
app.disable("x-powered-by");
|
||||
|
||||
// CORS minimale (solo per /api/*)
|
||||
app.use("/api", (req, res, next) => {
|
||||
res.set("Access-Control-Allow-Origin", "*");
|
||||
res.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
res.set("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
||||
if (req.method === "OPTIONS") return res.status(204).end();
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check (no body parser necessario)
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true, version: process.env.LATEST_VERSION || "" });
|
||||
});
|
||||
|
||||
// WEBHOOK Lemon Squeezy: deve ricevere il body RAW per verificare la firma.
|
||||
// Va registrato PRIMA del json parser globale.
|
||||
app.post(
|
||||
"/api/webhook/lemonsqueezy",
|
||||
express.raw({ type: "application/json", limit: "1mb" }),
|
||||
ls.webhook,
|
||||
);
|
||||
|
||||
// JSON parser per tutti gli altri endpoint
|
||||
app.use(express.json({ limit: "128kb" }));
|
||||
|
||||
// Licenze
|
||||
app.post("/api/license/activate", license.activate);
|
||||
app.post("/api/license/validate", license.validate);
|
||||
app.post("/api/license/deactivate", license.deactivate);
|
||||
|
||||
// Aggiornamenti + download firmato
|
||||
app.get("/api/latest", updates.latest);
|
||||
app.get("/api/download", updates.download);
|
||||
|
||||
// 404 JSON solo per /api/*
|
||||
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
|
||||
|
||||
// Error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error("[unhandled]", err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: "Internal error" });
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.PORT || 4002);
|
||||
const HOST = process.env.HOST || "127.0.0.1";
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[musictools-api] listening on ${HOST}:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { one } from "./db.js";
|
||||
import { verifyJwt } from "./jwt.js";
|
||||
|
||||
const BUILDS_DIR = process.env.BUILDS_DIR || path.resolve(process.cwd(), "../builds");
|
||||
const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300);
|
||||
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||
|
||||
function signPayload(payload, secret) {
|
||||
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
function buildDownloadUrl(filePath) {
|
||||
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
|
||||
const sig = signPayload(`${filePath}.${exp}`, process.env.JWT_SECRET);
|
||||
return `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(filePath)}&exp=${exp}&sig=${sig}`;
|
||||
}
|
||||
|
||||
// GET /api/latest?platform=macos|windows¤t=v1.5.2
|
||||
export async function latest(req, res) {
|
||||
const platform = String(req.query.platform || "").toLowerCase();
|
||||
if (platform !== "macos" && platform !== "windows") {
|
||||
return res.status(400).json({ error: "platform must be macos or windows" });
|
||||
}
|
||||
|
||||
const row = await one(
|
||||
`SELECT version, platform, file_path, size_bytes, sha256, notes, published_at
|
||||
FROM releases
|
||||
WHERE platform=?
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1`,
|
||||
[platform],
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return res.json({
|
||||
version: process.env.LATEST_VERSION || "",
|
||||
notes: "",
|
||||
download_url: "",
|
||||
requires_license: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Auth opzionale (Bearer): senza, niente download URL
|
||||
const auth = req.get("Authorization") || "";
|
||||
let licensed = false;
|
||||
if (auth.startsWith("Bearer ")) {
|
||||
const claims = verifyJwt(auth.slice(7).trim(), process.env.JWT_SECRET);
|
||||
licensed = !!claims;
|
||||
}
|
||||
|
||||
res.json({
|
||||
version: row.version,
|
||||
notes: row.notes || "",
|
||||
sha256: row.sha256 || "",
|
||||
size_bytes: Number(row.size_bytes || 0),
|
||||
download_url: licensed ? buildDownloadUrl(row.file_path) : "",
|
||||
requires_license: !licensed,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/download?file=...&exp=...&sig=...
|
||||
// Verifica firma e stream del file da disco.
|
||||
export async function download(req, res) {
|
||||
const file = String(req.query.file || "");
|
||||
const exp = Number(req.query.exp || 0);
|
||||
const sig = String(req.query.sig || "");
|
||||
if (!file || !exp || !sig) return res.status(400).send("Missing params");
|
||||
|
||||
const expected = signPayload(`${file}.${exp}`, process.env.JWT_SECRET);
|
||||
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||
return res.status(401).send("Invalid signature");
|
||||
}
|
||||
if (exp < Math.floor(Date.now() / 1000)) {
|
||||
return res.status(410).send("URL expired");
|
||||
}
|
||||
|
||||
// Sicurezza path: il file deve trovarsi sotto BUILDS_DIR.
|
||||
// 'file' arriva come "v1.5.3/MusicTools-macOS.zip"
|
||||
const abs = path.resolve(BUILDS_DIR, file);
|
||||
if (!abs.startsWith(path.resolve(BUILDS_DIR) + path.sep)) {
|
||||
return res.status(403).send("Forbidden");
|
||||
}
|
||||
if (!fs.existsSync(abs)) {
|
||||
return res.status(404).send("File not found");
|
||||
}
|
||||
|
||||
const name = path.basename(abs);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { json, badRequest, unauthorized } from "./http";
|
||||
import { verifyJwt } from "./jwt";
|
||||
import type { Env } from "./worker";
|
||||
|
||||
interface ReleaseRow {
|
||||
version: string;
|
||||
platform: string;
|
||||
r2_key: string;
|
||||
size_bytes: number | null;
|
||||
sha256: string | null;
|
||||
notes: string | null;
|
||||
published_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/latest?platform=macos|windows¤t=v1.5.2
|
||||
* Authorization: Bearer <token> (opzionale ma necessario per ricevere download_url)
|
||||
*
|
||||
* Risposta:
|
||||
* {
|
||||
* version, notes, sha256,
|
||||
* download_url (firmato, scade in DOWNLOAD_URL_TTL_SECONDS) -- solo se token valido
|
||||
* }
|
||||
*/
|
||||
export async function handleLatest(req: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const platform = (url.searchParams.get("platform") || "").toLowerCase();
|
||||
if (platform !== "macos" && platform !== "windows") {
|
||||
return badRequest("platform must be macos or windows");
|
||||
}
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
`SELECT version, platform, r2_key, size_bytes, sha256, notes, published_at
|
||||
FROM releases
|
||||
WHERE platform = ?1
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1`
|
||||
).bind(platform).first<ReleaseRow>();
|
||||
|
||||
if (!row) {
|
||||
return json({
|
||||
version: env.LATEST_VERSION || "",
|
||||
notes: "",
|
||||
download_url: "",
|
||||
requires_license: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Auth opzionale: senza token rispondiamo solo con metadata (version + notes).
|
||||
const auth = req.headers.get("Authorization") || "";
|
||||
let licensed = false;
|
||||
if (auth.startsWith("Bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
const claims = await verifyJwt(token, env.JWT_SECRET);
|
||||
licensed = !!claims;
|
||||
}
|
||||
|
||||
let downloadUrl = "";
|
||||
if (licensed) {
|
||||
// R2 non genera URL firmati nativi via Workers SDK in modo semplice.
|
||||
// Soluzione: serviamo il file via questo Worker su un path firmato HMAC
|
||||
// con scadenza. /api/download?key=<r2_key>&exp=<ts>&sig=<hmac>
|
||||
downloadUrl = await signDownloadUrl(env, row.r2_key);
|
||||
}
|
||||
|
||||
return json({
|
||||
version: row.version,
|
||||
notes: row.notes || "",
|
||||
sha256: row.sha256 || "",
|
||||
size_bytes: row.size_bytes || 0,
|
||||
download_url: downloadUrl,
|
||||
requires_license: !licensed,
|
||||
});
|
||||
}
|
||||
|
||||
async function signDownloadUrl(env: Env, r2Key: string): Promise<string> {
|
||||
// Implementazione minima: torniamo un URL relativo che un altro endpoint
|
||||
// /api/download verifichera prima di servire il file da R2.
|
||||
// Per ora restituisco un placeholder; vai a implementare /api/download
|
||||
// in un secondo passaggio se vuoi servire i binari dietro firma.
|
||||
const ttl = parseInt(env.DOWNLOAD_URL_TTL_SECONDS || "300", 10);
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
const payload = `${r2Key}.${exp}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(env.JWT_SECRET),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false, ["sign"],
|
||||
);
|
||||
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
|
||||
const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf)))
|
||||
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
return `https://musictools.djluza.com/api/download?key=${encodeURIComponent(r2Key)}&exp=${exp}&sig=${sig}`;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* MusicTools License & Update API
|
||||
*
|
||||
* Endpoints:
|
||||
* POST /api/license/activate body: { key, email, device_id, device_name, app_version }
|
||||
* POST /api/license/validate body: { token, device_id, app_version }
|
||||
* POST /api/license/deactivate body: { token, device_id }
|
||||
* GET /api/latest?platform=macos|windows¤t=v1.5.2 [Authorization: Bearer <token>]
|
||||
* POST /api/webhook/lemonsqueezy (firmato HMAC, crea licenza dopo ordine)
|
||||
*
|
||||
* Auth model:
|
||||
* - L'app non ha account: la "verita" e' (license_key, email).
|
||||
* - Dopo activate(), il server emette un JWT HMAC con claims
|
||||
* { sub: license_id, key_id, email, device_id, iat, exp }.
|
||||
* Il client lo salva e lo manda a ogni revalidate / /api/latest.
|
||||
* - revoke = update licenses.status='revoked' + tutti i validate falliscono.
|
||||
*/
|
||||
|
||||
import { handleActivate, handleValidate, handleDeactivate } from "./license";
|
||||
import { handleLatest } from "./updates";
|
||||
import { handleLemonSqueezyWebhook } from "./lemonsqueezy";
|
||||
import { json, methodNotAllowed, notFound } from "./http";
|
||||
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
BUILDS: R2Bucket;
|
||||
JWT_SECRET: string;
|
||||
LEMONSQUEEZY_SIGNING_SECRET: string;
|
||||
RESEND_API_KEY: string;
|
||||
LATEST_VERSION: string;
|
||||
MAX_ACTIVATIONS: string;
|
||||
TOKEN_TTL_DAYS: string;
|
||||
DOWNLOAD_URL_TTL_SECONDS: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
const method = req.method.toUpperCase();
|
||||
|
||||
// CORS (utile se in futuro vuoi chiamare l'API dalla landing page)
|
||||
if (method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type,Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (path === "/api/license/activate") {
|
||||
if (method !== "POST") return methodNotAllowed();
|
||||
return await handleActivate(req, env);
|
||||
}
|
||||
if (path === "/api/license/validate") {
|
||||
if (method !== "POST") return methodNotAllowed();
|
||||
return await handleValidate(req, env);
|
||||
}
|
||||
if (path === "/api/license/deactivate") {
|
||||
if (method !== "POST") return methodNotAllowed();
|
||||
return await handleDeactivate(req, env);
|
||||
}
|
||||
if (path === "/api/latest") {
|
||||
if (method !== "GET") return methodNotAllowed();
|
||||
return await handleLatest(req, env);
|
||||
}
|
||||
if (path === "/api/webhook/lemonsqueezy") {
|
||||
if (method !== "POST") return methodNotAllowed();
|
||||
return await handleLemonSqueezyWebhook(req, env);
|
||||
}
|
||||
if (path === "/api/health") {
|
||||
return json({ ok: true, version: env.LATEST_VERSION });
|
||||
}
|
||||
return notFound();
|
||||
} catch (err) {
|
||||
console.error("Unhandled error:", err);
|
||||
return json({ error: "Internal error" }, 500);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user