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:
s4luorth
2026-06-07 14:41:38 +02:00
commit ecb5e1bd22
37 changed files with 4390 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
data
*.db
*.db-*
.git

View File

@@ -0,0 +1,27 @@
# Copy to .env and fill in. NEVER commit the real .env.
# Strong random token the n8n workflow (and you) use for admin endpoints.
# Generate e.g. with: openssl rand -hex 32
ADMIN_API_TOKEN=change-me-to-a-long-random-string
# Public port on the host (container always listens on 8080 internally).
PORT=8080
# Comma-separated product slugs to seed on boot ("slug:Display Name").
# Add a new entry here whenever you ship another plugin.
SEED_PRODUCTS=gdpr-content-blocker:GDPR Content Blocker
# Absolute, public base URL of this backend (behind your TLS reverse proxy).
# Used to build the package download links handed to WordPress.
PUBLIC_BASE_URL=https://hub.lucas-orth.de
# Secret for signing time-limited download tokens. Generate with:
# openssl rand -hex 32
# If left empty, ADMIN_API_TOKEN is used as a fallback.
DOWNLOAD_SECRET=
# Name of the existing Docker network that Nginx Proxy Manager runs on, so NPM
# can reach this container as "license-backend:8080". Find it with:
# docker network ls
# Typical values: npm_default, nginxproxymanager_default, proxy
NPM_NETWORK=npm_default

7
license-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
.env
data/
*.db
*.db-shm
*.db-wal
npm-debug.log*

View File

@@ -0,0 +1,24 @@
# Node 20 on glibc so better-sqlite3 uses prebuilt binaries (no native build).
FROM node:20-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
# Install dependencies first for better layer caching.
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
# App source.
COPY src ./src
# Persistent data directory (mounted as a volume in compose).
RUN mkdir -p /data && chown -R node:node /data /app
VOLUME ["/data"]
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "src/server.js"]

170
license-backend/README.md Normal file
View File

@@ -0,0 +1,170 @@
# License Backend
Self-hosted, multi-product license server for WordPress plugins.
Stateless API + SQLite on a persistent volume. No prices anywhere — tiers are
expressed purely as **activation limits** (1 / 3 / -1 = unlimited). Pricing lives
on your website, not here.
## Architecture
```
PayPal (one-time payment)
│ webhook
n8n workflow
│ POST /api/v1/licenses (X-Admin-Token) ← generate key
License Backend (this container) ──► SQLite @ /data (Docker volume, survives redeploy)
│ returns { key }
n8n → e-mail key to customer + trigger invoice separately
```
The plugin later calls the **public** endpoints (`/activate`, `/validate`,
`/deactivate`) with the key, its product slug and the site domain.
## Run
```bash
cp .env.example .env
# edit .env: set a strong ADMIN_API_TOKEN (openssl rand -hex 32)
docker compose up -d --build
```
Put a TLS-terminating reverse proxy (Caddy / nginx / Traefik) in front and point
`hub.lucas-orth.de` at it. The container speaks plain HTTP on its port.
### Data persistence
The SQLite DB lives in the named volume `license-data` mounted at `/data`.
`docker compose down && up` or a fresh image build keeps all keys and
activations. Only `docker compose down -v` would wipe it.
Back up with:
```bash
docker run --rm -v license-data:/data -v "$PWD":/backup alpine \
sh -c "cp /data/license.db /backup/license-backup-$(date +%F).db"
```
## Adding a new plugin (extensibility)
Each plugin = one **product slug**. Two ways to add one:
1. Edit `SEED_PRODUCTS` in `.env` (`gdpr-content-blocker:Content Blocker,my-next-plugin:My Next Plugin`) and restart — idempotent, existing data untouched.
2. At runtime: `POST /api/v1/products` (admin) with `{ "slug": "...", "name": "..." }`.
Keys are always issued **per product**, so one backend serves all your plugins.
## API
### Admin (header `X-Admin-Token: <token>`)
**Generate a key — this is what n8n calls:**
```bash
curl -X POST https://hub.lucas-orth.de/api/v1/licenses \
-H "X-Admin-Token: $ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "product": "gdpr-content-blocker", "max_activations": 1, "email": "kunde@example.com" }'
# → 201 { "ok": true, "key": "ABCD-EFGH-JKMN-PQRS", ... }
```
`max_activations`: `1` (single site), `3` (three sites), `-1` (unlimited).
Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime).
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/v1/licenses` | generate a key |
| GET | `/api/v1/licenses/:key` | inspect (status + bound domains) |
| POST | `/api/v1/licenses/:key/disable` | revoke |
| POST | `/api/v1/licenses/:key/enable` | un-revoke |
| GET | `/api/v1/products` | list products |
| POST | `/api/v1/products` | add a product |
| POST | `/api/v1/releases?product=&version=` | upload a plugin ZIP (raw body) |
| GET | `/api/v1/releases/:product` | list releases |
**Upload a new plugin version** (raw `.zip` as the body — no multipart):
```bash
curl -X POST "https://hub.lucas-orth.de/api/v1/releases?product=gdpr-content-blocker&version=1.1.0" \
-H "X-Admin-Token: $ADMIN_API_TOKEN" \
-H "Content-Type: application/zip" \
-H "X-Changelog: Fixes XYZ" \
-H "X-Tested: 6.7" -H "X-Requires-PHP: 8.1" \
--data-binary @gdpr-content-blocker.zip
```
The ZIP **must** contain a top-level `gdpr-content-blocker/` folder (so WordPress
extracts it into the right plugin directory). Stored under
`/data/releases/<product>/<version>.zip` (persists across redeploys).
### Public (called by the plugin)
| Method | Path | Body | Purpose |
|---|---|---|---|
| POST | `/api/v1/activate` | `{ key, product, domain }` | bind a domain, enforce limit |
| POST | `/api/v1/validate` | `{ key, product, domain }` | daily re-check |
| POST | `/api/v1/deactivate` | `{ key, product, domain }` | free a slot |
| POST | `/api/v1/scan` | `{ key, product, domain, urls? }` | visit the site, list embedded third-party resources |
**Scan** is SSRF-guarded on three levels: (1) the requesting domain must be an
**activated** slot of the license; (2) every target URL's host must equal that
domain; (3) the host is DNS-resolved and refused if it points at a private,
loopback or link-local IP (e.g. `169.254.169.254` cloud metadata). Redirects are
**not** followed (`redirect: manual`) so a 30x can't bounce the fetch into an
internal host. `urls` is optional — defaults to `https://<domain>/`; max 10 URLs,
10 s timeout, 2 MB per page. Residual risk: DNS-rebinding (TOCTOU between resolve
and fetch) is not fully closed; run the backend in a network segment without
access to sensitive internal services.
Response:
```json
{
"ok": true,
"scanned": [{ "url": "https://kunde.de/", "error": null }],
"findings": [
{ "host": "google.com", "third_party": true, "types": ["iframe"],
"count": 1, "sample_urls": ["https://www.google.com/maps/embed?..."],
"suggested_pattern": "google.com" }
]
}
```
Success: `{ "ok": true, "status": "valid", "activations_used": 1, "max_activations": 1 }`
Failure: `{ "ok": false, "error": "Maximale Anzahl an Domains für diese Lizenz erreicht" }`
**Update flow** (called by the plugin's updater):
| Method | Path | Body / Query | Purpose |
|---|---|---|---|
| POST | `/api/v1/update` | `{ key, product, domain, version }` | is there a newer version? returns a signed package URL |
| GET | `/api/v1/download` | `?token=…` | stream the ZIP; token is HMAC-signed, 7-day TTL, license re-checked live |
`/update` returns either `{ ok:true, update_available:false }` or
`{ ok:true, update_available:true, version, package, changelog, requires, tested, requires_php }`.
The `package` URL embeds a signed token (key+product+version+expiry); the download
endpoint verifies the signature **and** re-checks that the license is still active
before streaming — so a leaked URL stops working once the license is revoked.
`GET /healthz``{ ok: true }` (used by the Docker healthcheck).
## Shipping a release from Gitea
Tag a release in your plugin repo and let Gitea Actions build the ZIP and push it
here. Example `.gitea/workflows/release.yml` lives in the **plugin** repo; it runs
`on: push: tags: ['v*']`, zips the `gdpr-content-blocker/` folder, and `curl`s it to
`POST /api/v1/releases`. Store `ADMIN_API_TOKEN` and the backend URL as Gitea
**secrets**. You can also just run that `curl` by hand for a one-off release.
## n8n workflow (outline)
1. **Webhook / PayPal trigger** — receives the IPN/webhook of a completed payment.
2. **Map item → tier**: set `max_activations` from the purchased item
(your website knows which PayPal button = which tier; the backend never sees a price).
3. **HTTP Request**`POST {backend}/api/v1/licenses` with `X-Admin-Token`, body
`{ product, max_activations, email }`.
4. **Send e-mail** with `{{$json.key}}` to the customer.
5. **Trigger invoice** (separate node / sub-workflow).
## Security notes
- `ADMIN_API_TOKEN` is required; the server refuses to boot without it.
- Admin auth uses a timing-safe comparison.
- Run only behind HTTPS. Restrict the admin endpoints at the proxy to n8n's IP if possible.
- The container runs as the non-root `node` user.
```

View File

@@ -0,0 +1,33 @@
services:
license-backend:
build: .
image: license-backend:latest
container_name: license-backend
restart: unless-stopped
env_file: .env
environment:
- PORT=8080
- DATA_DIR=/data
# No public port mapping: Nginx Proxy Manager reaches the container over the
# shared Docker network as "license-backend:8080". (For a quick local test
# without NPM, uncomment the ports block below.)
# ports:
# - "127.0.0.1:8080:8080"
expose:
- "8080"
volumes:
# Named volume → data (SQLite DB) survives container recreation/redeploy.
- license-data:/data
networks:
- npm
networks:
# The network Nginx Proxy Manager already runs on. Find it with:
# docker network ls
# then set NPM_NETWORK in .env (e.g. NPM_NETWORK=npm_default).
npm:
external: true
name: ${NPM_NETWORK:-npm_default}
volumes:
license-data:

View File

@@ -0,0 +1,22 @@
{
"name": "license-backend",
"version": "1.0.0",
"description": "Self-hosted license server for WordPress plugins (extensible, multi-product).",
"author": "Lucas Orth",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test:unit": "node test/util.test.mjs && node test/scan.test.mjs",
"test:integration": "node --import ./test/register.mjs ./test/integration.mjs"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.21.0"
}
}

79
license-backend/src/db.js Normal file
View 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
View 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
}

View 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}`);
});

View 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;
}
}

View File

@@ -0,0 +1,11 @@
import { pathToFileURL } from 'node:url';
import { resolve as pathResolve } from 'node:path';
const shim = pathToFileURL( pathResolve( 'test/shim-better-sqlite3.mjs' ) ).href;
export async function resolve( specifier, context, next ) {
if ( specifier === 'better-sqlite3' ) {
return { url: shim, shortCircuit: true };
}
return next( specifier, context );
}

View File

@@ -0,0 +1,212 @@
// End-to-end test of the REAL server.js (better-sqlite3 shimmed to node:sqlite).
import { rmSync } from 'node:fs';
const PORT = 8799;
const TOKEN = 'test-admin-token';
const BASE = `http://127.0.0.1:${PORT}`;
process.env.ADMIN_API_TOKEN = TOKEN;
process.env.PORT = String(PORT);
process.env.DATA_DIR = './.testdata';
process.env.SEED_PRODUCTS = 'gdpr-content-blocker:Content Blocker';
process.env.PUBLIC_BASE_URL = BASE;
rmSync('./.testdata', { recursive: true, force: true });
let fail = 0;
const ok = (name, cond, extra = '') => {
console.log((cond ? '[PASS] ' : '[FAIL] ') + name + (cond ? '' : ' ' + extra));
if (!cond) fail++;
};
async function admin(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: { 'Content-Type': 'application/json', 'X-Admin-Token': TOKEN },
body: body ? JSON.stringify(body) : undefined,
});
return { status: r.status, json: await r.json() };
}
async function pub(path, body) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: r.status, json: await r.json() };
}
async function adminRaw(path, buffer, headers = {}) {
const r = await fetch(BASE + path, {
method: 'POST',
headers: { 'Content-Type': 'application/zip', 'X-Admin-Token': TOKEN, ...headers },
body: buffer,
});
return { status: r.status, json: await r.json() };
}
// Boot the real server.
await import('../src/server.js');
await new Promise((r) => setTimeout(r, 400));
try {
const P = 'gdpr-content-blocker';
// Health
ok('healthz', (await (await fetch(BASE + '/healthz')).json()).ok === true);
// Admin auth required
const noAuth = await fetch(BASE + '/api/v1/licenses', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: P, max_activations: 1 }),
});
ok('generate without token → 401', noAuth.status === 401);
// Generate single-site key (max=1)
const g1 = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 1, email: 'a@b.de' });
ok('generate key 201', g1.status === 201 && g1.json.ok === true, JSON.stringify(g1.json));
const key1 = g1.json.key;
ok('key format', /^[A-Z2-9]{4}(-[A-Z2-9]{4}){3}$/.test(key1 || ''), key1);
// Activate domain A
const a1 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'https://www.Kunde-A.de/' });
ok('activate A valid', a1.json.status === 'valid' && a1.json.activations_used === 1, JSON.stringify(a1.json));
// Re-activate same domain (idempotent, count stays 1)
const a1b = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('re-activate A idempotent', a1b.json.ok && a1b.json.activations_used === 1, JSON.stringify(a1b.json));
// Activate second domain → limit reached (max=1), response lists bound domains
const a2 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-b.de' });
ok('second domain → 409 limit', a2.status === 409 && a2.json.ok === false, JSON.stringify(a2.json));
ok('409 reports code limit_reached', a2.json.code === 'limit_reached', JSON.stringify(a2.json));
ok('409 lists occupied domain', Array.isArray(a2.json.domains) && a2.json.domains.includes('kunde-a.de'), JSON.stringify(a2.json));
// Validate activated domain
const v1 = await pub('/api/v1/validate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('validate A valid', v1.json.status === 'valid', JSON.stringify(v1.json));
// Validate non-activated domain
const v2 = await pub('/api/v1/validate', { key: key1, product: P, domain: 'kunde-x.de' });
ok('validate unbound → 403', v2.status === 403 && v2.json.ok === false, JSON.stringify(v2.json));
// Deactivate A, then B should fit
const d1 = await pub('/api/v1/deactivate', { key: key1, product: P, domain: 'kunde-a.de' });
ok('deactivate A', d1.json.ok === true);
const a3 = await pub('/api/v1/activate', { key: key1, product: P, domain: 'kunde-b.de' });
ok('after deactivate, B fits', a3.json.status === 'valid', JSON.stringify(a3.json));
// 3-site key
const g3 = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 3 });
const key3 = g3.json.key;
for (const d of ['s1.de', 's2.de', 's3.de']) {
const r = await pub('/api/v1/activate', { key: key3, product: P, domain: d });
ok('3-site activate ' + d, r.json.status === 'valid', JSON.stringify(r.json));
}
const g3over = await pub('/api/v1/activate', { key: key3, product: P, domain: 's4.de' });
ok('3-site 4th → 409', g3over.status === 409, JSON.stringify(g3over.json));
// Unlimited key
const gU = await admin('POST', '/api/v1/licenses', { product: P, max_activations: -1 });
const keyU = gU.json.key;
let allOk = true;
for (let i = 0; i < 10; i++) {
const r = await pub('/api/v1/activate', { key: keyU, product: P, domain: `u${i}.de` });
if (r.json.status !== 'valid') allOk = false;
}
ok('unlimited activates 10 domains', allOk);
// Disable a license → activation blocked
await admin('POST', `/api/v1/licenses/${key3}/disable`);
const disabled = await pub('/api/v1/activate', { key: key3, product: P, domain: 'new.de' });
ok('disabled license → 403', disabled.status === 403, JSON.stringify(disabled.json));
// Wrong product
const wrong = await pub('/api/v1/activate', { key: key1, product: 'nope', domain: 'kunde-b.de' });
ok('unknown product → 404', wrong.status === 404, JSON.stringify(wrong.json));
// Unknown key
const unk = await pub('/api/v1/activate', { key: 'ZZZZ-ZZZZ-ZZZZ-ZZZZ', product: P, domain: 'x.de' });
ok('unknown key → 404', unk.status === 404, JSON.stringify(unk.json));
// Invalid max_activations
const badMax = await admin('POST', '/api/v1/licenses', { product: P, max_activations: 0 });
ok('max_activations 0 → 400', badMax.status === 400, JSON.stringify(badMax.json));
// Add a new product (extensibility) and issue a key for it
const np = await admin('POST', '/api/v1/products', { slug: 'next-plugin', name: 'Next Plugin' });
ok('add product 201', np.status === 201);
const npKey = await admin('POST', '/api/v1/licenses', { product: 'next-plugin', max_activations: 1 });
ok('key for new product', npKey.status === 201 && npKey.json.ok, JSON.stringify(npKey.json));
// Inspect a license
const info = await admin('GET', `/api/v1/licenses/${key1}`);
ok('inspect shows 1 domain (kunde-b)', info.json.activations_used === 1 && info.json.domains[0].domain === 'kunde-b.de', JSON.stringify(info.json));
// ── Scan endpoint guards ──
// Unactivated domain → 403
const scanUnbound = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-a.de' });
ok('scan unbound domain → 403', scanUnbound.status === 403, JSON.stringify(scanUnbound.json));
// SSRF guard: target url on a different host than the licensed domain → 400
const scanWrongHost = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-b.de', urls: ['https://evil.example.org/'] });
ok('scan foreign host filtered → 400', scanWrongHost.status === 400, JSON.stringify(scanWrongHost.json));
// Private/loopback licensed domain rejected
const scanLocal = await pub('/api/v1/scan', { key: key1, product: P, domain: '127.0.0.1' });
ok('scan private domain → 400', scanLocal.status === 400, JSON.stringify(scanLocal.json));
// Valid bound domain: host is unreachable in test, so endpoint returns ok:true
// with a per-page fetch error and empty findings (pipeline wiring works).
const scanOk = await pub('/api/v1/scan', { key: key1, product: P, domain: 'kunde-b.de' });
ok('scan bound domain → 200 ok', scanOk.status === 200 && scanOk.json.ok === true, JSON.stringify(scanOk.json));
ok('scan reports the attempted page', Array.isArray(scanOk.json.scanned) && scanOk.json.scanned.length === 1, JSON.stringify(scanOk.json));
// ── Update delivery ──
// A minimal but valid ZIP (PK signature). key1 is active on kunde-b.de.
const zip = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4, 5, 6, 7, 8]);
const upBad = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=bad', zip);
ok('upload bad version → 400', upBad.status === 400, JSON.stringify(upBad.json));
const upEmpty = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=1.1.0', Buffer.alloc(0));
ok('upload empty body → 400', upEmpty.status === 400, JSON.stringify(upEmpty.json));
const up = await adminRaw('/api/v1/releases?product=gdpr-content-blocker&version=1.1.0', zip, { 'X-Changelog': 'Neues' });
ok('upload release 201', up.status === 201 && up.json.bytes === zip.length, JSON.stringify(up.json));
// No update when current >= latest
const uNo = await pub('/api/v1/update', { key: key1, product: P, domain: 'kunde-b.de', version: '1.1.0' });
ok('update: current==latest → none', uNo.json.ok && uNo.json.update_available === false, JSON.stringify(uNo.json));
// Update available when current < latest
const uYes = await pub('/api/v1/update', { key: key1, product: P, domain: 'kunde-b.de', version: '1.0.0' });
ok('update available', uYes.json.update_available === true && uYes.json.version === '1.1.0', JSON.stringify(uYes.json));
ok('update has package url', typeof uYes.json.package === 'string' && uYes.json.package.includes('/api/v1/download?token='), JSON.stringify(uYes.json));
// Unactivated domain cannot check updates
const uUnbound = await pub('/api/v1/update', { key: key1, product: P, domain: 'fremd.de', version: '1.0.0' });
ok('update unbound domain → 403', uUnbound.status === 403, JSON.stringify(uUnbound.json));
// Download via the signed package URL → real ZIP bytes
const dl = await fetch(uYes.json.package);
const dlBuf = Buffer.from(await dl.arrayBuffer());
ok('download 200 zip', dl.status === 200 && dl.headers.get('content-type') === 'application/zip', String(dl.status));
ok('download bytes match upload', dlBuf.length === zip.length && dlBuf[0] === 0x50 && dlBuf[1] === 0x4b);
// Tampered token rejected
const badToken = uYes.json.package.replace('token=', 'token=x');
const dlBad = await fetch(badToken);
ok('download tampered token → 403', dlBad.status === 403, String(dlBad.status));
// Admin can list releases
const relList = await admin('GET', '/api/v1/releases/gdpr-content-blocker');
ok('release list shows 1.1.0', relList.json.ok && relList.json.releases.some((r) => r.version === '1.1.0'), JSON.stringify(relList.json));
} catch (e) {
console.error(e);
fail++;
}
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
try { rmSync('./.testdata', { recursive: true, force: true }); } catch { /* WAL handle still open; harmless */ }
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,4 @@
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register( './test/hooks.mjs', pathToFileURL( './' ) );

View File

@@ -0,0 +1,87 @@
import { extractResources, analyze, isPublicHost, isPrivateIp } from '../src/scan.js';
let fail = 0;
const ok = (name, cond, extra = '') => {
console.log((cond ? '[PASS] ' : '[FAIL] ') + name + (cond ? '' : ' ' + extra));
if (!cond) fail++;
};
const html = `
<!DOCTYPE html><html><head>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto">
<script src="/wp-includes/js/jquery.js"></script>
<script src="https://www.googletagmanager.com/gtag/js?id=G-XYZ"></script>
</head><body>
<img src="https://cdn.example.com/logo.png">
<img src="/local/pic.png">
<iframe src="https://www.google.com/maps/embed?pb=1" width="600" height="450"></iframe>
<iframe src="https://www.youtube-nocookie.com/embed/abc"></iframe>
<iframe src="data:text/html,nope"></iframe>
<object data="https://player.vimeo.com/video/123"></object>
<a href="https://twitter.com/foo">x</a>
</body></html>`;
const base = 'https://example.com/page/';
const resources = extractResources(html, base);
ok('finds google maps iframe', resources.some((r) => r.type === 'iframe' && r.url.includes('google.com/maps')));
ok('finds youtube-nocookie iframe', resources.some((r) => r.url.includes('youtube-nocookie.com/embed')));
ok('skips data: iframe', !resources.some((r) => r.url.startsWith('data:')));
ok('finds external script (gtm)', resources.some((r) => r.type === 'script' && r.url.includes('googletagmanager.com')));
ok('resolves relative script to absolute', resources.some((r) => r.url === 'https://example.com/wp-includes/js/jquery.js'));
ok('finds external img', resources.some((r) => r.type === 'img' && r.url.includes('cdn.example.com')));
ok('finds link stylesheet', resources.some((r) => r.type === 'link' && r.url.includes('fonts.googleapis.com')));
ok('finds object data (vimeo)', resources.some((r) => r.type === 'object' && r.url.includes('player.vimeo.com')));
ok('ignores anchor href', !resources.some((r) => r.url.includes('twitter.com')));
const findings = analyze([{ url: base, resources }], 'example.com');
const byHost = Object.fromEntries(findings.map((f) => [f.host, f]));
ok('example.com is first-party', byHost['example.com'] && byHost['example.com'].third_party === false);
ok('cdn.example.com is first-party (subdomain)', byHost['cdn.example.com'].third_party === false);
ok('google.com is third-party', byHost['google.com'].third_party === true);
ok('googletagmanager third-party', byHost['googletagmanager.com'].third_party === true);
ok('third-party sorted first', findings[0].third_party === true);
ok('suggested_pattern = host', byHost['player.vimeo.com'].suggested_pattern === 'player.vimeo.com');
ok('types aggregated', Array.isArray(byHost['google.com'].types) && byHost['google.com'].types.includes('iframe'));
ok('sample_urls capped at 3', findings.every((f) => f.sample_urls.length <= 3));
ok('pages list source page', Array.isArray(byHost['google.com'].pages) && byHost['google.com'].pages.includes(base));
// pages aggregate across multiple scanned pages
const multi = analyze([
{ url: 'https://example.com/a', resources: [{ url: 'https://google.com/maps', type: 'iframe' }] },
{ url: 'https://example.com/b', resources: [{ url: 'https://google.com/maps', type: 'iframe' }] },
], 'example.com');
ok('host found on both pages', multi[0].pages.length === 2 && multi[0].pages.includes('https://example.com/a') && multi[0].pages.includes('https://example.com/b'));
// www normalization: www.google.com counts as google.com
const f2 = analyze([{ url: base, resources: [{ url: 'https://www.google.com/x', type: 'iframe' }] }], 'example.com');
ok('www stripped in host grouping', f2[0].host === 'google.com');
// isPublicHost
ok('localhost not public', isPublicHost('localhost') === false);
ok('127.0.0.1 not public', isPublicHost('127.0.0.1') === false);
ok('10.x not public', isPublicHost('10.1.2.3') === false);
ok('192.168 not public', isPublicHost('192.168.0.1') === false);
ok('172.16 not public', isPublicHost('172.16.0.1') === false);
ok('172.32 IS public', isPublicHost('172.32.0.1') === true);
ok('public domain ok', isPublicHost('kunde-a.de') === true);
// isPrivateIp (post-DNS-resolution SSRF guard)
ok('metadata 169.254.169.254 private', isPrivateIp('169.254.169.254') === true);
ok('127.0.0.1 private', isPrivateIp('127.0.0.1') === true);
ok('10.x private', isPrivateIp('10.0.0.5') === true);
ok('192.168 private', isPrivateIp('192.168.1.1') === true);
ok('172.16 private', isPrivateIp('172.16.5.5') === true);
ok('172.32 public ip', isPrivateIp('172.32.0.1') === false);
ok('CGNAT 100.64 private', isPrivateIp('100.64.0.1') === true);
ok('public ip allowed', isPrivateIp('93.184.216.34') === false);
ok('::1 private', isPrivateIp('::1') === true);
ok('fe80 link-local private', isPrivateIp('fe80::1') === true);
ok('fd00 ula private', isPrivateIp('fd12::1') === true);
ok('ipv4-mapped metadata private', isPrivateIp('::ffff:169.254.169.254') === true);
ok('global ipv6 allowed', isPrivateIp('2606:2800:220:1:248:1893:25c8:1946') === false);
ok('empty ip unsafe', isPrivateIp('') === true);
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
process.exit(fail === 0 ? 0 : 1);

View File

@@ -0,0 +1,24 @@
// Test-only shim: maps the better-sqlite3 API onto Node's built-in node:sqlite.
// Used ONLY for local integration testing (production uses real better-sqlite3
// inside the Docker image). The SQL executed is identical.
import { DatabaseSync } from 'node:sqlite';
class Statement {
constructor(stmt) {
this.stmt = stmt;
if (typeof stmt.setAllowBareNamedParameters === 'function') {
stmt.setAllowBareNamedParameters(true);
}
}
run(...args) { return this.stmt.run(...args); }
get(...args) { return this.stmt.get(...args); }
all(...args) { return this.stmt.all(...args); }
}
export default class Database {
constructor(path) { this.db = new DatabaseSync(path); }
pragma(s) { return this.db.exec('PRAGMA ' + s + ';'); }
exec(sql) { return this.db.exec(sql); }
prepare(sql) { return new Statement(this.db.prepare(sql)); }
close() { this.db.close(); }
}

View File

@@ -0,0 +1,50 @@
import { generateKey, normalizeDomain, safeEqual, isExpired, compareVersions, signToken, verifyToken } from '../src/util.js';
let fail = 0;
const ok = (name, cond) => { console.log((cond ? '[PASS] ' : '[FAIL] ') + name); if (!cond) fail++; };
// Key format
const k = generateKey();
ok('key format XXXX-XXXX-XXXX-XXXX', /^[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/.test(k));
ok('no ambiguous chars (0OI1L)', !/[0OI1L]/.test(k));
const keys = new Set(Array.from({ length: 1000 }, () => generateKey()));
ok('1000 keys unique', keys.size === 1000);
// Domain normalization
ok('strip https + www', normalizeDomain('https://www.Example.com/') === 'example.com');
ok('strip path', normalizeDomain('http://example.com/foo/bar') === 'example.com');
ok('strip port', normalizeDomain('example.com:8080') === 'example.com');
ok('subdomain kept', normalizeDomain('https://shop.example.com/x') === 'shop.example.com');
ok('empty stays empty', normalizeDomain('') === '');
ok('null safe', normalizeDomain(null) === '');
// safeEqual
ok('safeEqual match', safeEqual('abc123', 'abc123') === true);
ok('safeEqual mismatch', safeEqual('abc', 'abd') === false);
ok('safeEqual length diff', safeEqual('abc', 'abcd') === false);
// isExpired
ok('no expiry = not expired', isExpired(null) === false);
ok('past = expired', isExpired('2000-01-01T00:00:00Z') === true);
ok('future = not expired', isExpired('2999-01-01T00:00:00Z') === false);
ok('garbage = not expired', isExpired('not-a-date') === false);
// compareVersions
ok('1.1.0 > 1.0.0', compareVersions('1.1.0', '1.0.0') === 1);
ok('1.0.0 < 1.0.1', compareVersions('1.0.0', '1.0.1') === -1);
ok('1.0 == 1.0.0', compareVersions('1.0', '1.0.0') === 0);
ok('2.0.0 > 1.9.9', compareVersions('2.0.0', '1.9.9') === 1);
ok('10.0 > 9.0 (numeric not lexical)', compareVersions('10.0', '9.0') === 1);
// signToken / verifyToken
const secret = 'top-secret';
const tok = signToken({ k: 'KEY', p: 'gdpr-content-blocker', v: '1.1.0', exp: Date.now() + 10000 }, secret);
const payload = verifyToken(tok, secret);
ok('token round-trips', payload && payload.k === 'KEY' && payload.v === '1.1.0');
ok('wrong secret → null', verifyToken(tok, 'other') === null);
ok('tampered body → null', verifyToken('x' + tok, secret) === null);
ok('expired token → null', verifyToken(signToken({ exp: Date.now() - 1 }, secret), secret) === null);
ok('garbage token → null', verifyToken('not-a-token', secret) === null);
console.log('\n' + (fail === 0 ? 'ALL PASSED' : fail + ' FAILED'));
process.exit(fail === 0 ? 0 : 1);