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>
96 lines
3.2 KiB
JavaScript
96 lines
3.2 KiB
JavaScript
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);
|
|
}
|