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