- 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>
802 lines
30 KiB
JavaScript
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}`);
|
|
});
|