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:
79
license-backend/src/db.js
Normal file
79
license-backend/src/db.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||
const DB_PATH = `${DATA_DIR}/license.db`;
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
export const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
max_activations INTEGER NOT NULL, -- -1 = unlimited
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
|
||||
email TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT -- NULL = lifetime
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
domain TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_check TEXT NOT NULL,
|
||||
UNIQUE(license_id, domain)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
version TEXT NOT NULL,
|
||||
zip_path TEXT NOT NULL,
|
||||
changelog TEXT,
|
||||
requires TEXT, -- min WordPress version
|
||||
tested TEXT, -- tested-up-to WordPress version
|
||||
requires_php TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(product_id, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_licenses_key ON licenses(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_activations_license ON activations(license_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_product ON releases(product_id);
|
||||
`);
|
||||
|
||||
/**
|
||||
* Seed product slugs from the SEED_PRODUCTS env var (comma separated
|
||||
* "slug:Name" pairs). Safe to run on every boot — only inserts missing ones.
|
||||
* This is how the backend stays extensible: add a slug here for each new plugin.
|
||||
*/
|
||||
export function seedProducts() {
|
||||
const raw = process.env.SEED_PRODUCTS || 'gdpr-content-blocker:GDPR Content Blocker';
|
||||
const now = new Date().toISOString();
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO products (slug, name, created_at) VALUES (?, ?, ?)`
|
||||
);
|
||||
for (const pair of raw.split(',')) {
|
||||
const [slug, ...nameParts] = pair.split(':');
|
||||
const s = (slug || '').trim();
|
||||
if (!s) continue;
|
||||
const name = (nameParts.join(':').trim()) || s;
|
||||
insert.run(s, name, now);
|
||||
}
|
||||
}
|
||||
132
license-backend/src/scan.js
Normal file
132
license-backend/src/scan.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Website scanner: given fetched HTML, list third-party resources (iframes,
|
||||
// scripts, images, etc.) so the site owner sees which external providers are
|
||||
// embedded. Pure functions — no network here, so they are unit-testable.
|
||||
|
||||
const TAG_RE = /<(iframe|script|img|link|source|video|audio|embed|object)\b([^>]*)>/gi;
|
||||
|
||||
function attr(tagBody, name) {
|
||||
const m = tagBody.match(new RegExp(name + '\\s*=\\s*["\\\']([^"\\\']+)["\\\']', 'i'));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function normHost(h) {
|
||||
return String(h || '').toLowerCase().replace(/:.*$/, '').replace(/^www\./, '');
|
||||
}
|
||||
|
||||
/** Extract absolute resource URLs (with a type tag) from one HTML document. */
|
||||
export function extractResources(html, baseUrl) {
|
||||
const out = [];
|
||||
let m;
|
||||
TAG_RE.lastIndex = 0;
|
||||
while ((m = TAG_RE.exec(html)) !== null) {
|
||||
const tag = m[1].toLowerCase();
|
||||
const body = m[2];
|
||||
|
||||
let raw = '';
|
||||
if (tag === 'object') raw = attr(body, 'data');
|
||||
else if (tag === 'link') raw = attr(body, 'href');
|
||||
else raw = attr(body, 'src');
|
||||
|
||||
if (!raw) continue;
|
||||
if (/^(data:|blob:|javascript:|mailto:|tel:|#|about:)/i.test(raw)) continue;
|
||||
|
||||
let abs;
|
||||
try {
|
||||
abs = new URL(raw, baseUrl).href;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!/^https?:/i.test(abs)) continue;
|
||||
|
||||
out.push({ url: abs, type: tag });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate resources from one or more scanned pages into per-host findings.
|
||||
* `pages` = [{ url, resources: [{url,type}] }]. `siteHost` is the first-party host.
|
||||
*/
|
||||
export function analyze(pages, siteHost) {
|
||||
const site = normHost(siteHost);
|
||||
const byHost = new Map();
|
||||
|
||||
for (const page of pages) {
|
||||
for (const r of page.resources || []) {
|
||||
let host;
|
||||
try {
|
||||
host = normHost(new URL(r.url).host);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const thirdParty = !(host === site || host.endsWith('.' + site));
|
||||
|
||||
if (!byHost.has(host)) {
|
||||
byHost.set(host, { host, third_party: thirdParty, types: new Set(), sample_urls: [], pages: new Set(), count: 0 });
|
||||
}
|
||||
const f = byHost.get(host);
|
||||
f.count++;
|
||||
f.types.add(r.type);
|
||||
if (page.url) f.pages.add(page.url);
|
||||
if (f.sample_urls.length < 3 && !f.sample_urls.includes(r.url)) {
|
||||
f.sample_urls.push(r.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byHost.values()]
|
||||
.map((f) => ({
|
||||
host: f.host,
|
||||
third_party: f.third_party,
|
||||
types: [...f.types].sort(),
|
||||
sample_urls: f.sample_urls,
|
||||
pages: [...f.pages], // which scanned pages embed this host
|
||||
count: f.count,
|
||||
// A sensible default match_pattern suggestion for the plugin.
|
||||
suggested_pattern: f.host,
|
||||
}))
|
||||
.sort((a, b) => (a.third_party === b.third_party ? b.count - a.count : a.third_party ? -1 : 1));
|
||||
}
|
||||
|
||||
/** Reject loopback / private-range literals to limit SSRF blast radius. */
|
||||
export function isPublicHost(host) {
|
||||
const h = normHost(host);
|
||||
if (h === 'localhost' || h === '' || h.endsWith('.localhost')) return false;
|
||||
if (/^127\./.test(h) || h === '::1' || h === '0.0.0.0') return false;
|
||||
if (/^10\./.test(h)) return false;
|
||||
if (/^192\.168\./.test(h)) return false;
|
||||
if (/^169\.254\./.test(h)) return false;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a resolved IP address is private/loopback/link-local and must not be
|
||||
* fetched. Used after DNS resolution to block SSRF where a public hostname
|
||||
* resolves to an internal address (e.g. cloud metadata 169.254.169.254).
|
||||
* Unknown/unparseable → treated as unsafe.
|
||||
*/
|
||||
export function isPrivateIp(ip) {
|
||||
if (!ip) return true;
|
||||
const s = String(ip).toLowerCase();
|
||||
|
||||
if (s === '::1') return true; // IPv6 loopback
|
||||
if (s.startsWith('fe80')) return true; // IPv6 link-local
|
||||
if (s.startsWith('fc') || s.startsWith('fd')) return true; // IPv6 unique-local
|
||||
|
||||
// IPv4 (incl. IPv4-mapped IPv6 ::ffff:a.b.c.d)
|
||||
const mapped = s.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
const ip4 = mapped ? mapped[1] : s;
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip4)) {
|
||||
const p = ip4.split('.').map(Number);
|
||||
if (p.some((n) => n > 255)) return true; // malformed → unsafe
|
||||
if (p[0] === 0 || p[0] === 10 || p[0] === 127) return true;
|
||||
if (p[0] === 169 && p[1] === 254) return true; // link-local + metadata
|
||||
if (p[0] === 192 && p[1] === 168) return true;
|
||||
if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
|
||||
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; // CGNAT
|
||||
return false;
|
||||
}
|
||||
|
||||
return false; // global IPv6 / other → allow
|
||||
}
|
||||
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}`);
|
||||
});
|
||||
92
license-backend/src/util.js
Normal file
92
license-backend/src/util.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { randomInt, timingSafeEqual, createHmac } from 'node:crypto';
|
||||
|
||||
// Unambiguous alphabet (no 0/O/1/I/L) for human-readable, dictation-safe keys.
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
|
||||
/** Generate a license key like ABCD-EFGH-JKMN-PQRS. */
|
||||
export function generateKey(groups = 4, groupLen = 4) {
|
||||
const parts = [];
|
||||
for (let g = 0; g < groups; g++) {
|
||||
let s = '';
|
||||
for (let i = 0; i < groupLen; i++) {
|
||||
s += ALPHABET[randomInt(0, ALPHABET.length)];
|
||||
}
|
||||
parts.push(s);
|
||||
}
|
||||
return parts.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a domain for stable comparison:
|
||||
* strip scheme, path, port, leading www, lowercase.
|
||||
*/
|
||||
export function normalizeDomain(input) {
|
||||
if (!input || typeof input !== 'string') return '';
|
||||
let d = input.trim().toLowerCase();
|
||||
d = d.replace(/^[a-z]+:\/\//, ''); // scheme
|
||||
d = d.replace(/\/.*$/, ''); // path
|
||||
d = d.replace(/:.*$/, ''); // port
|
||||
d = d.replace(/^www\./, ''); // www
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Timing-safe string compare for tokens. */
|
||||
export function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a || ''));
|
||||
const bb = Buffer.from(String(b || ''));
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** True if an ISO date string is in the past. */
|
||||
export function isExpired(expiresAt) {
|
||||
if (!expiresAt) return false;
|
||||
const t = Date.parse(expiresAt);
|
||||
if (Number.isNaN(t)) return false;
|
||||
return t < Date.now();
|
||||
}
|
||||
|
||||
/** Compare dotted version strings. Returns 1 if a>b, -1 if a<b, 0 if equal. */
|
||||
export function compareVersions(a, b) {
|
||||
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
||||
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
||||
const len = Math.max(pa.length, pb.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = pa[i] || 0;
|
||||
const y = pb[i] || 0;
|
||||
if (x > y) return 1;
|
||||
if (x < y) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Sign a small JSON payload (download tokens). Returns "body.sig" (base64url). */
|
||||
export function signToken(payload, secret) {
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const sig = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
return body + '.' + sig;
|
||||
}
|
||||
|
||||
/** Verify a token from signToken. Returns the payload, or null if bad/expired. */
|
||||
export function verifyToken(token, secret) {
|
||||
if (typeof token !== 'string' || !token.includes('.')) return null;
|
||||
const [body, sig] = token.split('.');
|
||||
if (!body || !sig) return null;
|
||||
|
||||
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
||||
if (payload.exp && Date.now() > payload.exp) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user