Files
GDPR-Content-Blocker/license-backend/README.md
s4luorth e691b675cd feat: release per URL registrieren (gitea-asset) - /api/v1/releases/from-url
- 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>
2026-06-07 15:51:19 +02:00

7.9 KiB

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:

  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:

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)

  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 RequestPOST {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.