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

184 lines
7.9 KiB
Markdown

# 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) |
| 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):
```bash
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):
```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.
```