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>
This commit is contained in:
s4luorth
2026-06-07 15:51:19 +02:00
parent 576ad1f74a
commit e691b675cd
4 changed files with 102 additions and 0 deletions

View File

@@ -20,6 +20,12 @@ PUBLIC_BASE_URL=https://hub.lucas-orth.de
# If left empty, ADMIN_API_TOKEN is used as a fallback. # If left empty, ADMIN_API_TOKEN is used as a fallback.
DOWNLOAD_SECRET= 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 # 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: # can reach this container as "license-backend:8080". Find it with:
# docker network ls # docker network ls

View File

@@ -79,8 +79,21 @@ Optional: `email`, `note`, `expires_at` (ISO date; omit for lifetime).
| GET | `/api/v1/products` | list products | | GET | `/api/v1/products` | list products |
| POST | `/api/v1/products` | add a product | | POST | `/api/v1/products` | add a product |
| POST | `/api/v1/releases?product=&version=` | upload a plugin ZIP (raw body) | | 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 | | 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): **Upload a new plugin version** (raw `.zip` as the body — no multipart):
```bash ```bash
curl -X POST "https://hub.lucas-orth.de/api/v1/releases?product=gdpr-content-blocker&version=1.1.0" \ curl -X POST "https://hub.lucas-orth.de/api/v1/releases?product=gdpr-content-blocker&version=1.1.0" \

View File

@@ -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). // Absolute base URL used to build package download links (behind your proxy).
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 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 }); mkdirSync(RELEASES_DIR, { recursive: true });
const PORT = Number(process.env.PORT || 8080); 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. // List releases for a product.
app.get('/api/v1/releases/:product', adminOnly, (req, res) => { app.get('/api/v1/releases/:product', adminOnly, (req, res) => {
const product = Q.productBySlug.get(req.params.product); const product = Q.productBySlug.get(req.params.product);

View File

@@ -202,6 +202,19 @@ try {
const relList = await admin('GET', '/api/v1/releases/gdpr-content-blocker'); 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)); 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) { } catch (e) {
console.error(e); console.error(e);
fail++; fail++;