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:
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user