chore: monorepo - plugin, backend und hilfsdaten in einem repo
- Eltern-Ordner ist jetzt EIN Git-Repo (statt getrennter Repos). - root .gitignore haelt Secrets (.env), node_modules, DB und Build-Artefakte raus. - release.ps1: manueller Release (ZIP bauen + ans Backend laden). - root README mit Struktur und Release-Ablauf. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
574
license-backend/src/server.js
Normal file
574
license-backend/src/server.js
Normal file
@@ -0,0 +1,574 @@
|
||||
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(/\/+$/, '');
|
||||
|
||||
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 });
|
||||
}
|
||||
);
|
||||
|
||||
// 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 })),
|
||||
});
|
||||
});
|
||||
|
||||
// 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user