- Aendert max_activations (z.B. -1 unbegrenzt), email, note, expires_at, status. - Partielles Update, validiert; nur erlaubte Felder. Tests + README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
185 lines
8.0 KiB
Markdown
185 lines
8.0 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) |
|
|
| PATCH | `/api/v1/licenses/:key` | modify (seat limit, email, note, expiry, status) |
|
|
| 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.
|
|
```
|