Files
GDPR-Content-Blocker/license-backend/src/server.js
s4luorth 3228965c60 fix: tabs ohne seiten-rahmen, scan aller unterseiten, button-radius bei hover (v1.5.3)
- Admin-tabs: kein rahmen/box/outline in irgendeinem zustand mehr - nur die
  untere unterstreichung markiert den aktiven tab (WP-default-borders ueber-
  schrieben, auch :focus/:active).
- Scan deckt jetzt ALLE veroeffentlichten seiten/beitraege (alle public post
  types) ab statt nur 4. Backend: limit 10 -> 60 URLs und PARALLELE abfrage
  (concurrency 12, 8s/seite), plugin-AJAX-timeout 45 -> 90s.
- Platzhalter-button: border-radius in hover/focus/active festgenagelt (3px) ->
  theme kann die ecken beim hover nicht mehr veraendern.

+ version 1.5.3. (Backend-redeploy noetig fuer den scan.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:19:57 +02:00

802 lines
30 KiB
JavaScript

import express from 'express';
import http from 'node:http';
import https from 'node:https';
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 = 60; // pages scanned per request (covers whole small/medium sites)
const MAX_SCAN_BYTES = 2_000_000;
const SCAN_TIMEOUT_MS = 8_000;
const SCAN_CONCURRENCY = 12; // fetch pages in parallel so "all pages" stays fast
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 });
/**
* HTTP(S) GET pinned to a pre-resolved, already-validated IP address. The socket
* connects to `address` while SNI/Host stay the original hostname, so TLS still
* verifies against the certificate. This closes the DNS-rebinding/TOCTOU gap
* where the global fetch() would re-resolve the host (possibly to a private IP)
* AFTER our isPrivateIp() check. Does not follow redirects.
* Resolves to { status, headers, buffer }; rejects on network/timeout error.
*/
function pinnedRequest(targetUrl, address, family, { maxBytes, timeoutMs, headers = {} }) {
return new Promise((resolve, reject) => {
const u = new URL(targetUrl);
const mod = u.protocol === 'https:' ? https : http;
let settled = false;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
fn(arg);
};
const req = mod.request(
targetUrl,
{
method: 'GET',
headers,
// Force the connection to the validated IP (handles both lookup
// callback signatures: with and without options.all).
lookup: (_hostname, opts, cb) =>
opts && opts.all ? cb(null, [{ address, family }]) : cb(null, address, family),
},
(res) => {
const chunks = [];
let bytes = 0;
const result = () => ({ status: res.statusCode || 0, headers: res.headers, buffer: Buffer.concat(chunks) });
res.on('data', (c) => {
if (settled) return;
bytes += c.length;
if (bytes <= maxBytes) {
chunks.push(c);
} else {
// Cap the body: stop reading and resolve with what we have now.
// (destroy() suppresses 'end', so we must settle here ourselves.)
res.destroy();
finish(resolve, result());
}
});
res.on('end', () => finish(resolve, result()));
res.on('error', (e) => finish(reject, e));
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error('request timed out')));
req.on('error', (e) => finish(reject, e));
req.end();
});
}
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 = ?'),
allLicenses: db.prepare(`
SELECT l.key, l.status, l.max_activations, l.email, l.note,
l.created_at, l.expires_at, p.slug AS product,
(SELECT COUNT(*) FROM activations a WHERE a.license_id = l.id) AS activations_used
FROM licenses l
JOIN products p ON p.id = l.product_id
ORDER BY l.created_at DESC
`),
licensesByEmail: db.prepare(`
SELECT l.key, l.status, l.max_activations, l.email, l.note,
l.created_at, l.expires_at, p.slug AS product,
(SELECT COUNT(*) FROM activations a WHERE a.license_id = l.id) AS activations_used
FROM licenses l
JOIN products p ON p.id = l.product_id
WHERE l.email = ? COLLATE NOCASE
ORDER BY l.created_at DESC
`),
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');
// Fetch one page (SSRF-guarded, IP-pinned, no redirects). Returns a page row.
async function scanOne(t) {
try {
const host = new URL(t).hostname;
let address, family;
try {
({ address, family } = await lookup(host));
} catch {
return { url: t, error: 'dns lookup failed', resources: [] };
}
if (isPrivateIp(address)) {
return { url: t, error: 'blocked: resolves to a private address', resources: [] };
}
const r = await pinnedRequest(t, address, family, {
maxBytes: MAX_SCAN_BYTES,
timeoutMs: SCAN_TIMEOUT_MS,
headers: { 'User-Agent': 'ContentBlockerScanner/1.0', Accept: 'text/html' },
});
if (r.status >= 300 && r.status < 400) {
return { url: t, error: `redirect (${r.status}) not followed`, resources: [] };
}
return { url: t, resources: extractResources(r.buffer.toString('utf8').slice(0, MAX_SCAN_BYTES), t) };
} catch (e) {
return { url: t, error: String(e?.message || e), resources: [] };
}
}
// Run with bounded concurrency so scanning all pages stays fast but doesn't
// hammer the target site.
const pages = new Array(targets.length);
let next = 0;
async function worker() {
while (next < targets.length) {
const idx = next++;
pages[idx] = await scanOne(targets[idx]);
}
}
await Promise.all(
Array.from({ length: Math.min(SCAN_CONCURRENCY, targets.length) }, worker)
);
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)');
}
// Fetch the ZIP, following redirects manually so EACH hop is re-validated
// (protocol + DNS → private-IP guard + IP pinning). The global fetch with
// redirect:'follow' would only check the first URL and could be bounced into
// an internal target.
let buf;
try {
const initialHost = url.hostname;
let current = zipUrl;
for (let hop = 0; ; hop++) {
if (hop > 5) return fail(res, 502, 'too many redirects for zip_url');
const u = new URL(current);
if (!/^https?:$/.test(u.protocol)) return fail(res, 400, 'zip_url redirect to non-http(s)');
let address, family;
try {
({ address, family } = await lookup(u.hostname));
} catch {
return fail(res, 400, 'dns lookup failed for zip_url');
}
if (isPrivateIp(address)) return fail(res, 400, 'zip_url resolves to a private address');
const headers = { 'User-Agent': 'ContentBlockerReleaseFetcher/1.0' };
// Only attach the Gitea token to the original host — never leak it to a
// redirect target.
if (GITEA_TOKEN && u.hostname === initialHost) headers.Authorization = 'token ' + GITEA_TOKEN;
const r = await pinnedRequest(current, address, family, {
maxBytes: MAX_ZIP_BYTES,
timeoutMs: 30000,
headers,
});
if (r.status >= 300 && r.status < 400 && r.headers.location) {
current = new URL(r.headers.location, current).href;
continue;
}
if (r.status < 200 || r.status >= 300) return fail(res, 502, 'fetch failed: HTTP ' + r.status);
buf = r.buffer;
break;
}
} 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,
});
});
// List all licenses (each row includes its email). Optional ?email=… filters to
// one customer's licenses (case-insensitive exact match).
app.get('/api/v1/licenses', adminOnly, (req, res) => {
const email = req.query.email ? String(req.query.email).trim() : '';
const rows = email ? Q.licensesByEmail.all(email) : Q.allLicenses.all();
return res.json({ ok: true, count: rows.length, licenses: rows });
});
// 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}`);
});