import express from 'express'; import { lookup } from 'node:dns/promises'; import { mkdirSync, writeFileSync, existsSync, statSync, createReadStream } from 'node:fs'; import { join } from 'node:path'; import { db, seedProducts } from './db.js'; import { generateKey, normalizeDomain, safeEqual, nowIso, isExpired, compareVersions, signToken, verifyToken, } from './util.js'; import { extractResources, analyze, isPublicHost, isPrivateIp } from './scan.js'; const MAX_SCAN_URLS = 10; const MAX_SCAN_BYTES = 2_000_000; const SCAN_TIMEOUT_MS = 10_000; const DATA_DIR = process.env.DATA_DIR || '/data'; const RELEASES_DIR = join(DATA_DIR, 'releases'); const MAX_ZIP_BYTES = 50 * 1024 * 1024; const DOWNLOAD_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days // Secret for signing download tokens. Falls back to the admin token if unset. const DOWNLOAD_SECRET = process.env.DOWNLOAD_SECRET || process.env.ADMIN_API_TOKEN || ''; // Absolute base URL used to build package download links (behind your proxy). const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); // Optional: restrict release-from-url fetches to this host prefix (e.g. your // Gitea), and a token for downloading assets from private repos. const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || '').replace(/\/+$/, ''); const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; mkdirSync(RELEASES_DIR, { recursive: true }); const PORT = Number(process.env.PORT || 8080); const ADMIN_TOKEN = process.env.ADMIN_API_TOKEN || ''; if (!ADMIN_TOKEN) { console.error('FATAL: ADMIN_API_TOKEN is not set. Refusing to start.'); process.exit(1); } seedProducts(); const app = express(); app.disable('x-powered-by'); app.set('trust proxy', true); // honor X-Forwarded-* from the reverse proxy // JSON parser for all endpoints EXCEPT the raw ZIP upload (mounted per-route). app.use((req, res, next) => { if (req.path === '/api/v1/releases' && req.method === 'POST') return next(); return express.json({ limit: '32kb' })(req, res, next); }); // Minimal security headers (TLS termination is expected at the reverse proxy). app.use((req, res, next) => { res.set('X-Content-Type-Options', 'nosniff'); res.set('Referrer-Policy', 'no-referrer'); next(); }); /* ───────────────────────── prepared statements ───────────────────────── */ const Q = { productBySlug: db.prepare('SELECT * FROM products WHERE slug = ?'), allProducts: db.prepare('SELECT slug, name, created_at FROM products ORDER BY slug'), insertProduct: db.prepare( 'INSERT INTO products (slug, name, created_at) VALUES (?, ?, ?)' ), licenseByKey: db.prepare('SELECT * FROM licenses WHERE key = ?'), insertLicense: db.prepare(` INSERT INTO licenses (key, product_id, max_activations, status, email, note, created_at, expires_at) VALUES (@key, @product_id, @max_activations, 'active', @email, @note, @created_at, @expires_at) `), setLicenseStatus: db.prepare('UPDATE licenses SET status = ? WHERE id = ?'), activationsForLicense: db.prepare( 'SELECT * FROM activations WHERE license_id = ? ORDER BY created_at' ), activationByDomain: db.prepare( 'SELECT * FROM activations WHERE license_id = ? AND domain = ?' ), countActivations: db.prepare( 'SELECT COUNT(*) AS n FROM activations WHERE license_id = ?' ), insertActivation: db.prepare( 'INSERT INTO activations (license_id, domain, created_at, last_check) VALUES (?, ?, ?, ?)' ), touchActivation: db.prepare('UPDATE activations SET last_check = ? WHERE id = ?'), deleteActivation: db.prepare( 'DELETE FROM activations WHERE license_id = ? AND domain = ?' ), upsertRelease: db.prepare(` INSERT INTO releases (product_id, version, zip_path, changelog, requires, tested, requires_php, created_at) VALUES (@product_id, @version, @zip_path, @changelog, @requires, @tested, @requires_php, @created_at) ON CONFLICT(product_id, version) DO UPDATE SET zip_path = excluded.zip_path, changelog = excluded.changelog, requires = excluded.requires, tested = excluded.tested, requires_php = excluded.requires_php, created_at = excluded.created_at `), releasesForProduct: db.prepare('SELECT * FROM releases WHERE product_id = ?'), releaseByVersion: db.prepare('SELECT * FROM releases WHERE product_id = ? AND version = ?'), }; /** Return the highest-version release row for a product, or null. */ function latestRelease(productId) { const rows = Q.releasesForProduct.all(productId); if (!rows.length) return null; return rows.reduce((best, r) => (compareVersions(r.version, best.version) > 0 ? r : best)); } /* ───────────────────────── helpers ───────────────────────── */ function adminOnly(req, res, next) { const token = req.get('X-Admin-Token') || ''; if (!safeEqual(token, ADMIN_TOKEN)) { return res.status(401).json({ ok: false, error: 'unauthorized' }); } next(); } function fail(res, code, error) { return res.status(code).json({ ok: false, error }); } /** * Resolve a license for a public request and run the common validity gates. * Returns { license, product } or sends an error response and returns null. */ function resolveLicense(res, key, productSlug) { if (!key || !productSlug) { fail(res, 400, 'key and product are required'); return null; } const product = Q.productBySlug.get(productSlug); if (!product) { fail(res, 404, 'unknown product'); return null; } const license = Q.licenseByKey.get(key); if (!license || license.product_id !== product.id) { fail(res, 404, 'Lizenz nicht gefunden'); return null; } if (license.status !== 'active') { fail(res, 403, 'Lizenz deaktiviert'); return null; } if (isExpired(license.expires_at)) { fail(res, 403, 'Lizenz abgelaufen'); return null; } return { license, product }; } /* ───────────────────────── public endpoints (plugin) ───────────────────────── */ // Activate a license for a domain (binds the slot, enforces the limit). app.post('/api/v1/activate', (req, res) => { const key = String(req.body?.key || '').trim(); const productSlug = String(req.body?.product || '').trim(); const domain = normalizeDomain(req.body?.domain); if (!domain) return fail(res, 400, 'domain is required'); const ctx = resolveLicense(res, key, productSlug); if (!ctx) return; const { license } = ctx; const existing = Q.activationByDomain.get(license.id, domain); if (existing) { Q.touchActivation.run(nowIso(), existing.id); return res.json({ ok: true, status: 'valid', domain, activations_used: Q.countActivations.get(license.id).n, max_activations: license.max_activations, }); } const used = Q.countActivations.get(license.id).n; if (license.max_activations !== -1 && used >= license.max_activations) { // Limit reached: tell the client which domains occupy the slots so the // user can free one and retry. const domains = Q.activationsForLicense.all(license.id).map((a) => a.domain); return res.status(409).json({ ok: false, code: 'limit_reached', error: 'Maximale Anzahl an Domains für diese Lizenz erreicht', domains, max_activations: license.max_activations, }); } const now = nowIso(); Q.insertActivation.run(license.id, domain, now, now); return res.json({ ok: true, status: 'valid', domain, activations_used: used + 1, max_activations: license.max_activations, }); }); // Validate an already-activated domain (used by the daily re-check). app.post('/api/v1/validate', (req, res) => { const key = String(req.body?.key || '').trim(); const productSlug = String(req.body?.product || '').trim(); const domain = normalizeDomain(req.body?.domain); if (!domain) return fail(res, 400, 'domain is required'); const ctx = resolveLicense(res, key, productSlug); if (!ctx) return; const { license } = ctx; const existing = Q.activationByDomain.get(license.id, domain); if (!existing) { return fail(res, 403, 'Domain ist für diese Lizenz nicht aktiviert'); } Q.touchActivation.run(nowIso(), existing.id); return res.json({ ok: true, status: 'valid', domain, activations_used: Q.countActivations.get(license.id).n, max_activations: license.max_activations, }); }); // Release a domain's activation slot. app.post('/api/v1/deactivate', (req, res) => { const key = String(req.body?.key || '').trim(); const productSlug = String(req.body?.product || '').trim(); const domain = normalizeDomain(req.body?.domain); if (!key || !productSlug) return fail(res, 400, 'key and product are required'); if (!domain) return fail(res, 400, 'domain is required'); const product = Q.productBySlug.get(productSlug); const license = product ? Q.licenseByKey.get(key) : null; if (license && license.product_id === product.id) { Q.deleteActivation.run(license.id, domain); } // Idempotent: always report success so the plugin can clean up locally. return res.json({ ok: true, status: 'deactivated', domain }); }); // Scan the licensed site for embedded third-party resources. // SSRF-guarded: only URLs on the license's own activated domain are fetched. app.post('/api/v1/scan', async (req, res) => { const key = String(req.body?.key || '').trim(); const productSlug = String(req.body?.product || '').trim(); const domain = normalizeDomain(req.body?.domain); if (!domain) return fail(res, 400, 'domain is required'); if (!isPublicHost(domain)) return fail(res, 400, 'domain must be a public host'); const ctx = resolveLicense(res, key, productSlug); if (!ctx) return; const { license } = ctx; // The requesting domain must be an activated slot of this license. if (!Q.activationByDomain.get(license.id, domain)) { return fail(res, 403, 'Domain ist für diese Lizenz nicht aktiviert'); } // Build the target list; default to the home page. let urls = Array.isArray(req.body?.urls) && req.body.urls.length ? req.body.urls : [`https://${domain}/`]; // Anti-SSRF: keep only http(s) URLs whose host is exactly the licensed domain. const targets = []; for (const u of urls.slice(0, MAX_SCAN_URLS)) { try { const url = new URL(String(u)); if (!/^https?:$/.test(url.protocol)) continue; if (normalizeDomain(url.host) !== domain) continue; if (!isPublicHost(url.host)) continue; targets.push(url.href); } catch { /* skip invalid */ } } if (!targets.length) return fail(res, 400, 'no valid target URLs for this domain'); const pages = []; for (const t of targets) { try { // SSRF hardening: resolve the host and refuse private/link-local IPs // (e.g. a public hostname pointed at 169.254.169.254 cloud metadata). const host = new URL(t).hostname; let address; try { ({ address } = await lookup(host)); } catch { pages.push({ url: t, error: 'dns lookup failed', resources: [] }); continue; } if (isPrivateIp(address)) { pages.push({ url: t, error: 'blocked: resolves to a private address', resources: [] }); continue; } const r = await fetch(t, { headers: { 'User-Agent': 'ContentBlockerScanner/1.0', Accept: 'text/html' }, redirect: 'manual', // do not auto-follow into unvalidated hosts signal: AbortSignal.timeout(SCAN_TIMEOUT_MS), }); if (r.status >= 300 && r.status < 400) { pages.push({ url: t, error: `redirect (${r.status}) not followed`, resources: [] }); continue; } const buf = await r.text(); pages.push({ url: t, resources: extractResources(buf.slice(0, MAX_SCAN_BYTES), t) }); } catch (e) { pages.push({ url: t, error: String(e?.message || e), resources: [] }); } } const findings = analyze(pages, domain); return res.json({ ok: true, scanned: pages.map((p) => ({ url: p.url, error: p.error || null })), findings, }); }); // Update check: tell a licensed, activated site whether a newer version exists // and hand back a signed, time-limited package download URL. app.post('/api/v1/update', (req, res) => { const key = String(req.body?.key || '').trim(); const productSlug = String(req.body?.product || '').trim(); const domain = normalizeDomain(req.body?.domain); const current = String(req.body?.version || '0').trim(); const ctx = resolveLicense(res, key, productSlug); if (!ctx) return; const { license, product } = ctx; if (domain && !Q.activationByDomain.get(license.id, domain)) { return fail(res, 403, 'Domain ist für diese Lizenz nicht aktiviert'); } const rel = latestRelease(product.id); if (!rel || compareVersions(rel.version, current) <= 0) { return res.json({ ok: true, update_available: false, version: rel ? rel.version : current }); } const token = signToken( { k: key, p: product.slug, v: rel.version, exp: Date.now() + DOWNLOAD_TTL_MS }, DOWNLOAD_SECRET ); const base = PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`; const pkg = `${base}/api/v1/download?token=${encodeURIComponent(token)}`; return res.json({ ok: true, update_available: true, version: rel.version, package: pkg, slug: product.slug, changelog: rel.changelog || '', requires: rel.requires || '', tested: rel.tested || '', requires_php: rel.requires_php || '', }); }); // Download a release ZIP. The token (issued by /update) carries the license key, // product and version; we re-check the license live before streaming. app.get('/api/v1/download', (req, res) => { const payload = verifyToken(String(req.query.token || ''), DOWNLOAD_SECRET); if (!payload) return fail(res, 403, 'invalid or expired token'); const product = Q.productBySlug.get(String(payload.p || '')); const license = product ? Q.licenseByKey.get(String(payload.k || '')) : null; if (!product || !license || license.product_id !== product.id) { return fail(res, 403, 'invalid license'); } if (license.status !== 'active' || isExpired(license.expires_at)) { return fail(res, 403, 'license not active'); } const rel = Q.releaseByVersion.get(product.id, String(payload.v || '')); if (!rel || !existsSync(rel.zip_path)) { return fail(res, 404, 'release not found'); } res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Length', statSync(rel.zip_path).size); res.setHeader( 'Content-Disposition', `attachment; filename="${product.slug}-${rel.version}.zip"` ); createReadStream(rel.zip_path).pipe(res); }); /* ───────────────────────── admin endpoints (n8n / you) ───────────────────────── */ // Upload / register a plugin release. Body is the raw .zip; metadata via query // (?product=&version=) and optional X-Changelog / X-Requires / X-Tested / // X-Requires-PHP headers. This is what your Gitea CI (or a curl) calls. app.post( '/api/v1/releases', adminOnly, express.raw({ type: ['application/zip', 'application/octet-stream'], limit: MAX_ZIP_BYTES }), (req, res) => { const productSlug = String(req.query.product || '').trim(); const version = String(req.query.version || '').trim(); const product = Q.productBySlug.get(productSlug); if (!product) return fail(res, 404, 'unknown product'); if (!/^\d+(\.\d+){0,3}$/.test(version)) { return fail(res, 400, 'version must look like 1.2.3'); } if (!Buffer.isBuffer(req.body) || req.body.length === 0) { return fail(res, 400, 'empty body — send the .zip as application/zip'); } // Basic ZIP signature check ("PK\x03\x04"). if (!(req.body[0] === 0x50 && req.body[1] === 0x4b)) { return fail(res, 400, 'body is not a ZIP file'); } const dir = join(RELEASES_DIR, productSlug); mkdirSync(dir, { recursive: true }); const zipPath = join(dir, `${version}.zip`); writeFileSync(zipPath, req.body); Q.upsertRelease.run({ product_id: product.id, version, zip_path: zipPath, changelog: req.get('X-Changelog') || null, requires: req.get('X-Requires') || null, tested: req.get('X-Tested') || null, requires_php: req.get('X-Requires-PHP') || null, created_at: nowIso(), }); return res.status(201).json({ ok: true, product: productSlug, version, bytes: req.body.length }); } ); // Register a release from a URL (e.g. a Gitea release asset). The backend // fetches the ZIP once and stores it locally, so the download to customer sites // stays license-gated. Body (JSON): { product, version, zip_url, changelog?, // requires?, tested?, requires_php? }. app.post('/api/v1/releases/from-url', adminOnly, async (req, res) => { const productSlug = String(req.body?.product || '').trim(); const version = String(req.body?.version || '').trim(); const zipUrl = String(req.body?.zip_url || '').trim(); const product = Q.productBySlug.get(productSlug); if (!product) return fail(res, 404, 'unknown product'); if (!/^\d+(\.\d+){0,3}$/.test(version)) return fail(res, 400, 'version must look like 1.2.3'); let url; try { url = new URL(zipUrl); } catch { return fail(res, 400, 'invalid zip_url'); } if (!/^https?:$/.test(url.protocol)) return fail(res, 400, 'zip_url must be http(s)'); if (GITEA_BASE_URL && !zipUrl.startsWith(GITEA_BASE_URL)) { return fail(res, 400, 'zip_url not allowed (must start with GITEA_BASE_URL)'); } // SSRF guard: refuse private/loopback targets. try { const { address } = await lookup(url.hostname); if (isPrivateIp(address)) return fail(res, 400, 'zip_url resolves to a private address'); } catch { return fail(res, 400, 'dns lookup failed for zip_url'); } let buf; try { const headers = { 'User-Agent': 'ContentBlockerReleaseFetcher/1.0' }; if (GITEA_TOKEN) headers.Authorization = 'token ' + GITEA_TOKEN; const r = await fetch(zipUrl, { headers, redirect: 'follow', signal: AbortSignal.timeout(30000) }); if (!r.ok) return fail(res, 502, 'fetch failed: HTTP ' + r.status); buf = Buffer.from(await r.arrayBuffer()); } catch (e) { return fail(res, 502, 'fetch error: ' + String(e?.message || e)); } if (buf.length === 0 || buf.length > MAX_ZIP_BYTES) return fail(res, 400, 'empty or too large'); if (!(buf[0] === 0x50 && buf[1] === 0x4b)) return fail(res, 400, 'downloaded file is not a ZIP'); const dir = join(RELEASES_DIR, productSlug); mkdirSync(dir, { recursive: true }); const zipPath = join(dir, `${version}.zip`); writeFileSync(zipPath, buf); Q.upsertRelease.run({ product_id: product.id, version, zip_path: zipPath, changelog: req.body?.changelog || null, requires: req.body?.requires || null, tested: req.body?.tested || null, requires_php: req.body?.requires_php || null, created_at: nowIso(), }); return res.status(201).json({ ok: true, product: productSlug, version, bytes: buf.length, source: zipUrl }); }); // List releases for a product. app.get('/api/v1/releases/:product', adminOnly, (req, res) => { const product = Q.productBySlug.get(req.params.product); if (!product) return fail(res, 404, 'unknown product'); const rows = Q.releasesForProduct.all(product.id) .map((r) => ({ version: r.version, created_at: r.created_at, changelog: r.changelog })) .sort((a, b) => compareVersions(b.version, a.version)); return res.json({ ok: true, product: product.slug, releases: rows }); }); // Generate a license key. This is the endpoint the n8n workflow calls. app.post('/api/v1/licenses', adminOnly, (req, res) => { const productSlug = String(req.body?.product || '').trim(); const maxRaw = req.body?.max_activations; const email = req.body?.email ? String(req.body.email).trim() : null; const note = req.body?.note ? String(req.body.note).trim() : null; const expiresAt = req.body?.expires_at ? String(req.body.expires_at).trim() : null; const product = Q.productBySlug.get(productSlug); if (!product) return fail(res, 404, 'unknown product'); const max = Number(maxRaw); if (!Number.isInteger(max) || (max < 1 && max !== -1)) { return fail(res, 400, 'max_activations must be a positive integer or -1 (unlimited)'); } // Generate a unique key (retry on the astronomically rare collision). let key; for (let attempt = 0; attempt < 5; attempt++) { key = generateKey(); if (!Q.licenseByKey.get(key)) break; key = null; } if (!key) return fail(res, 500, 'could not generate unique key'); Q.insertLicense.run({ key, product_id: product.id, max_activations: max, email, note, created_at: nowIso(), expires_at: expiresAt || null, }); return res.status(201).json({ ok: true, key, product: product.slug, max_activations: max, email, expires_at: expiresAt || null, }); }); // Inspect a license (status + bound domains). app.get('/api/v1/licenses/:key', adminOnly, (req, res) => { const license = Q.licenseByKey.get(req.params.key); if (!license) return fail(res, 404, 'not found'); const product = db.prepare('SELECT slug FROM products WHERE id = ?').get(license.product_id); const activations = Q.activationsForLicense.all(license.id); return res.json({ ok: true, key: license.key, product: product?.slug, status: license.status, max_activations: license.max_activations, email: license.email, note: license.note, created_at: license.created_at, expires_at: license.expires_at, activations_used: activations.length, domains: activations.map((a) => ({ domain: a.domain, since: a.created_at, last_check: a.last_check })), }); }); // Modify a license (e.g. change the seat limit). Partial update; only the // provided fields are changed. Body (JSON), any of: // max_activations (>=1 or -1), email, note, expires_at (ISO|null), status (active|disabled) app.patch('/api/v1/licenses/:key', adminOnly, (req, res) => { const license = Q.licenseByKey.get(req.params.key); if (!license) return fail(res, 404, 'not found'); const fields = {}; if ('max_activations' in req.body) { const max = Number(req.body.max_activations); if (!Number.isInteger(max) || (max < 1 && max !== -1)) { return fail(res, 400, 'max_activations must be a positive integer or -1 (unlimited)'); } fields.max_activations = max; } if ('email' in req.body) fields.email = req.body.email ? String(req.body.email).trim() : null; if ('note' in req.body) fields.note = req.body.note ? String(req.body.note).trim() : null; if ('expires_at' in req.body) fields.expires_at = req.body.expires_at ? String(req.body.expires_at).trim() : null; if ('status' in req.body) { const st = String(req.body.status); if (!['active', 'disabled'].includes(st)) return fail(res, 400, 'status must be active or disabled'); fields.status = st; } const cols = Object.keys(fields); // fixed allow-list above → safe to interpolate if (!cols.length) return fail(res, 400, 'no updatable fields provided'); const setClause = cols.map((c) => `${c} = @${c}`).join(', '); db.prepare(`UPDATE licenses SET ${setClause} WHERE id = @id`).run({ ...fields, id: license.id }); const u = Q.licenseByKey.get(req.params.key); return res.json({ ok: true, key: u.key, max_activations: u.max_activations, status: u.status, email: u.email, note: u.note, expires_at: u.expires_at, }); }); // Disable a license (revokes it everywhere on next check). app.post('/api/v1/licenses/:key/disable', adminOnly, (req, res) => { const license = Q.licenseByKey.get(req.params.key); if (!license) return fail(res, 404, 'not found'); Q.setLicenseStatus.run('disabled', license.id); return res.json({ ok: true, key: license.key, status: 'disabled' }); }); // Re-enable a disabled license. app.post('/api/v1/licenses/:key/enable', adminOnly, (req, res) => { const license = Q.licenseByKey.get(req.params.key); if (!license) return fail(res, 404, 'not found'); Q.setLicenseStatus.run('active', license.id); return res.json({ ok: true, key: license.key, status: 'active' }); }); // List / add products (extensibility for future plugins). app.get('/api/v1/products', adminOnly, (req, res) => { return res.json({ ok: true, products: Q.allProducts.all() }); }); app.post('/api/v1/products', adminOnly, (req, res) => { const slug = String(req.body?.slug || '').trim().toLowerCase(); const name = String(req.body?.name || '').trim() || slug; if (!/^[a-z0-9-]+$/.test(slug)) { return fail(res, 400, 'slug must match [a-z0-9-]+'); } if (Q.productBySlug.get(slug)) return fail(res, 409, 'product already exists'); Q.insertProduct.run(slug, name, nowIso()); return res.status(201).json({ ok: true, slug, name }); }); /* ───────────────────────── health ───────────────────────── */ app.get('/healthz', (req, res) => res.json({ ok: true, time: nowIso() })); app.use((req, res) => fail(res, 404, 'not found')); // JSON body parse errors etc. app.use((err, req, res, _next) => { if (err?.type === 'entity.parse.failed') return fail(res, 400, 'invalid JSON'); console.error(err); return fail(res, 500, 'internal error'); }); app.listen(PORT, () => { console.log(`License backend listening on :${PORT}`); });