- Admin-Endpoint laedt die ZIP einmal von einer URL (z.B. Gitea-Release-Asset), speichert sie lokal; Kunden-Download bleibt token-/lizenzgeschuetzt. - Guards: Produkt/Version/URL-Pruefung, GITEA_BASE_URL-Restriktion, DNS-SSRF-Schutz, optional GITEA_TOKEN fuer private Repos, ZIP-Signatur + 50MB-Limit. - env-Beispiele + README + Tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
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:
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:
- Edit
SEED_PRODUCTSin.env(gdpr-content-blocker:Content Blocker,my-next-plugin:My Next Plugin) and restart — idempotent, existing data untouched. - 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:
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) |
| POST | /api/v1/releases/from-url |
fetch a ZIP from a URL (e.g. Gitea release asset) |
| GET | /api/v1/releases/:product |
list releases |
Register a release from a Gitea release asset (no file upload — just JSON):
curl -X POST https://hub.lucas-orth.de/api/v1/releases/from-url \
-H "X-Admin-Token: $ADMIN_API_TOKEN" -H "Content-Type: application/json" \
-d '{"product":"gdpr-content-blocker","version":"1.1.0","zip_url":"https://gitea.lucas-orth.de/lucas.orth/GDPR-Content-Blocker/releases/download/v1.1.0/gdpr-content-blocker.zip"}'
The backend downloads the ZIP once and stores it locally, so the customer download
stays license-gated. The asset must be a built plugin ZIP (top-level
gdpr-content-blocker/ folder) attached to the Gitea release — the auto-generated
"Source code" archive of the monorepo will NOT work. Set GITEA_BASE_URL to
restrict fetches to your Gitea, and GITEA_TOKEN for private repos.
Upload a new plugin version (raw .zip as the body — no multipart):
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:
{
"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 curls 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)
- Webhook / PayPal trigger — receives the IPN/webhook of a completed payment.
- Map item → tier: set
max_activationsfrom the purchased item (your website knows which PayPal button = which tier; the backend never sees a price). - HTTP Request →
POST {backend}/api/v1/licenseswithX-Admin-Token, body{ product, max_activations, email }. - Send e-mail with
{{$json.key}}to the customer. - Trigger invoice (separate node / sub-workflow).
Security notes
ADMIN_API_TOKENis 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
nodeuser.