From e691b675cd831fc3de45410ed708f8f5b52e3a35 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Sun, 7 Jun 2026 15:51:19 +0200 Subject: [PATCH] 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 --- license-backend/.env.example | 6 +++ license-backend/README.md | 13 ++++++ license-backend/src/server.js | 70 ++++++++++++++++++++++++++++ license-backend/test/integration.mjs | 13 ++++++ 4 files changed, 102 insertions(+) diff --git a/license-backend/.env.example b/license-backend/.env.example index 393200a..4487770 100644 --- a/license-backend/.env.example +++ b/license-backend/.env.example @@ -20,6 +20,12 @@ PUBLIC_BASE_URL=https://hub.lucas-orth.de # If left empty, ADMIN_API_TOKEN is used as a fallback. DOWNLOAD_SECRET= +# Optional: for "release from URL" (POST /api/v1/releases/from-url). +# Restrict which host release ZIPs may be fetched from (recommended): +GITEA_BASE_URL=https://gitea.lucas-orth.de +# Token to download release assets from PRIVATE Gitea repos (leave empty if public): +GITEA_TOKEN= + # 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 diff --git a/license-backend/README.md b/license-backend/README.md index 9774d34..21c9948 100644 --- a/license-backend/README.md +++ b/license-backend/README.md @@ -79,8 +79,21 @@ Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime). | 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" \ diff --git a/license-backend/src/server.js b/license-backend/src/server.js index 34f2f1a..53929c7 100644 --- a/license-backend/src/server.js +++ b/license-backend/src/server.js @@ -28,6 +28,11 @@ const DOWNLOAD_SECRET = process.env.DOWNLOAD_SECRET || process.env.ADMIN_API_TOK // Absolute base URL used to build package download links (behind your proxy). const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); +// Optional: restrict release-from-url fetches to this host prefix (e.g. your +// Gitea), and a token for downloading assets from private repos. +const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || '').replace(/\/+$/, ''); +const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; + mkdirSync(RELEASES_DIR, { recursive: true }); const PORT = Number(process.env.PORT || 8080); @@ -446,6 +451,71 @@ app.post( } ); +// Register a release from a URL (e.g. a Gitea release asset). The backend +// fetches the ZIP once and stores it locally, so the download to customer sites +// stays license-gated. Body (JSON): { product, version, zip_url, changelog?, +// requires?, tested?, requires_php? }. +app.post('/api/v1/releases/from-url', adminOnly, async (req, res) => { + const productSlug = String(req.body?.product || '').trim(); + const version = String(req.body?.version || '').trim(); + const zipUrl = String(req.body?.zip_url || '').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'); + + let url; + try { + url = new URL(zipUrl); + } catch { + return fail(res, 400, 'invalid zip_url'); + } + if (!/^https?:$/.test(url.protocol)) return fail(res, 400, 'zip_url must be http(s)'); + if (GITEA_BASE_URL && !zipUrl.startsWith(GITEA_BASE_URL)) { + return fail(res, 400, 'zip_url not allowed (must start with GITEA_BASE_URL)'); + } + + // SSRF guard: refuse private/loopback targets. + try { + const { address } = await lookup(url.hostname); + if (isPrivateIp(address)) return fail(res, 400, 'zip_url resolves to a private address'); + } catch { + return fail(res, 400, 'dns lookup failed for zip_url'); + } + + let buf; + try { + const headers = { 'User-Agent': 'ContentBlockerReleaseFetcher/1.0' }; + if (GITEA_TOKEN) headers.Authorization = 'token ' + GITEA_TOKEN; + const r = await fetch(zipUrl, { headers, redirect: 'follow', signal: AbortSignal.timeout(30000) }); + if (!r.ok) return fail(res, 502, 'fetch failed: HTTP ' + r.status); + buf = Buffer.from(await r.arrayBuffer()); + } catch (e) { + return fail(res, 502, 'fetch error: ' + String(e?.message || e)); + } + + if (buf.length === 0 || buf.length > MAX_ZIP_BYTES) return fail(res, 400, 'empty or too large'); + if (!(buf[0] === 0x50 && buf[1] === 0x4b)) return fail(res, 400, 'downloaded file is not a ZIP'); + + const dir = join(RELEASES_DIR, productSlug); + mkdirSync(dir, { recursive: true }); + const zipPath = join(dir, `${version}.zip`); + writeFileSync(zipPath, buf); + + Q.upsertRelease.run({ + product_id: product.id, + version, + zip_path: zipPath, + changelog: req.body?.changelog || null, + requires: req.body?.requires || null, + tested: req.body?.tested || null, + requires_php: req.body?.requires_php || null, + created_at: nowIso(), + }); + + return res.status(201).json({ ok: true, product: productSlug, version, bytes: buf.length, source: zipUrl }); +}); + // List releases for a product. app.get('/api/v1/releases/:product', adminOnly, (req, res) => { const product = Q.productBySlug.get(req.params.product); diff --git a/license-backend/test/integration.mjs b/license-backend/test/integration.mjs index d973637..9b835d1 100644 --- a/license-backend/test/integration.mjs +++ b/license-backend/test/integration.mjs @@ -202,6 +202,19 @@ try { 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)); + // ── Release from URL: guards (real fetch not exercised offline) ── + const fuProd = await admin('POST', '/api/v1/releases/from-url', { product: 'nope', version: '1.2.0', zip_url: 'https://example.com/a.zip' }); + ok('from-url unknown product → 404', fuProd.status === 404, JSON.stringify(fuProd.json)); + + const fuVer = await admin('POST', '/api/v1/releases/from-url', { product: P, version: 'x', zip_url: 'https://example.com/a.zip' }); + ok('from-url bad version → 400', fuVer.status === 400, JSON.stringify(fuVer.json)); + + const fuUrl = await admin('POST', '/api/v1/releases/from-url', { product: P, version: '1.2.0', zip_url: 'not-a-url' }); + ok('from-url invalid url → 400', fuUrl.status === 400, JSON.stringify(fuUrl.json)); + + const fuLocal = await admin('POST', '/api/v1/releases/from-url', { product: P, version: '1.2.0', zip_url: 'http://127.0.0.1/a.zip' }); + ok('from-url private host → 400', fuLocal.status === 400, JSON.stringify(fuLocal.json)); + } catch (e) { console.error(e); fail++;