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

@@ -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);