Upload via GUI (324 Dateien)

This commit is contained in:
2026-11-02 21:48:45 +00:00
parent cf5fc48fba
commit df3eae4ee8
4 changed files with 113 additions and 27 deletions
+4
View File
@@ -33,6 +33,10 @@ RATE_LIMIT_PER_MIN=60
# Obergrenze für durchgereichte Dateien in MB. 0 = keine Grenze.
MAX_DOWNLOAD_MB=512
# Gleichzeitige Downloads insgesamt (weitere Anfragen bekommen 503 mit
# Retry-After). Schützt die Bandbreite. 0 = keine Grenze.
MAX_PARALLEL_DOWNLOADS=8
# Anzahl der Reverse-Proxy-Stufen davor:
# 0 = keine, der Dienst ist direkt erreichbar
# 1 = ein Proxy, z. B. Nginx Proxy Manager oder Caddy
+14 -1
View File
@@ -100,7 +100,20 @@ Der Dienst steht offen im Netz. Eingebaut ist:
- **Umleitungen** werden selbst verfolgt und bei jedem Sprung erneut gegen
die Host-Liste geprüft, höchstens vier Sprünge.
- **Ratenbegrenzung** je Absender, einstellbar über `RATE_LIMIT_PER_MIN`.
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`.
- **Größengrenze** für durchgereichte Dateien über `MAX_DOWNLOAD_MB`. Sie gilt
auch dann, wenn CurseForge keine Dateigröße angibt: Es wird mitgezählt und
bei Überschreitung abgebrochen.
- **Parallelgrenze** über `MAX_PARALLEL_DOWNLOADS` (Standard 8). Weitere
Downloads bekommen `503` mit `Retry-After`.
- **Zeitlimit** von 30 Sekunden für Antworten von CurseForge. Bricht der
Launcher mitten im Download ab, wird auch die Verbindung zu CurseForge
geschlossen.
- **Fehlversuche** beim Token zählen zur Ratenbegrenzung, ein Raten ist also
nicht unbegrenzt schnell möglich.
- **`/selftest`** läuft höchstens alle 30 Sekunden wirklich (fünf Anfragen an
CurseForge), dazwischen kommt das letzte Ergebnis mit
`"zwischengespeichert": true`. Das Token steckt im Launcher; ohne diese
Bremse ließe sich damit dein CurseForge-Kontingent aufbrauchen.
- **Token** über `CF_PROXY_TOKEN` – Pflicht: ohne Token startet der Dienst nicht
(bewusst offen nur mit `CF_PROXY_OFFEN=1`, z. B. im abgeschotteten Netz).
@@ -12,3 +12,6 @@ services:
read_only: true
security_opt:
- no-new-privileges:true
# keine Linux-Fähigkeiten nötig (Port 8420 liegt über 1024)
cap_drop:
- ALL
+76 -10
View File
@@ -2,7 +2,7 @@
const http = require('http');
const crypto = require('crypto');
const { Readable } = require('stream');
const { Readable, Transform, pipeline } = require('stream');
const KEY = (process.env.CF_API_KEY || '').trim();
const PORT = Number(process.env.PORT || 8420);
@@ -14,6 +14,15 @@ const TOKENS = new Set(
);
const LIMIT_PRO_MIN = Number(process.env.RATE_LIMIT_PER_MIN || 60);
const MAX_MB = Number(process.env.MAX_DOWNLOAD_MB || 512);
// gleichzeitige Downloads insgesamt – schützt die Bandbreite, falls jemand mit dem
// (im Launcher steckenden) Token viele große Dateien parallel zieht. 0 = keine Grenze.
const MAX_PARALLEL = Number(process.env.MAX_PARALLEL_DOWNLOADS ?? 8);
// wie lange CurseForge für eine Antwort brauchen darf, bevor die Anfrage abgebrochen wird
const UPSTREAM_TIMEOUT_MS = 30_000;
let laufendeDownloads = 0;
const SELFTEST_ABSTAND_MS = 30_000;
let selftestErgebnis = null;
let selftestZeit = 0;
const PROXY_STUFEN = Math.max(0, Number(process.env.TRUST_PROXY || 0) || 0);
@@ -157,6 +166,7 @@ async function dateilisteAufloesen(req, res) {
method: 'POST',
headers: cfKopfzeilen({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ fileIds: ids }),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
if (!antwort.ok) {
// Schlüsselprobleme nicht im Wortlaut weiterreichen
@@ -171,25 +181,48 @@ async function dateiDurchreichen(req, res, url) {
if (!ziel) return sendeJson(res, 400, { error: 'Adresse fehlt oder zeigt nicht auf das CurseForge-CDN.' });
// Weiterleitungen selbst verfolgen und jedes Mal den Host neu prüfen
if (MAX_PARALLEL > 0 && laufendeDownloads >= MAX_PARALLEL) {
res.setHeader('Retry-After', '5');
return sendeJson(res, 503, { error: 'Gerade zu viele Downloads, bitte gleich erneut versuchen.' });
}
laufendeDownloads++;
let frei = false;
const freigeben = () => { if (!frei) { frei = true; laufendeDownloads--; } };
try {
// Zeitlimit nur bis die Antwort-Köpfe da sind – der eigentliche Download darf länger dauern
const abbruch = new AbortController();
const frist = setTimeout(() => abbruch.abort(), UPSTREAM_TIMEOUT_MS);
res.on('close', () => abbruch.abort()); // Aufrufer weg -> CurseForge-Verbindung schließen
let aktuell = ziel;
let antwort = null;
for (let sprung = 0; sprung < 4; sprung++) {
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual' });
let umgeleitet = true;
try {
for (let sprung = 0; sprung < 4 && umgeleitet; sprung++) {
antwort = await fetch(aktuell, { headers: cfKopfzeilen(), redirect: 'manual', signal: abbruch.signal });
const weiter = antwort.headers.get('location');
if (antwort.status >= 300 && antwort.status < 400 && weiter) {
const naechste = pruefeDownloadZiel(new URL(weiter, aktuell).toString());
if (!naechste) return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' });
if (!naechste) { freigeben(); return sendeJson(res, 502, { error: 'Weiterleitung führt aus dem CurseForge-CDN heraus.' }); }
aktuell = naechste;
continue;
} else {
umgeleitet = false;
}
break;
}
} finally {
clearTimeout(frist);
}
if (umgeleitet) { freigeben(); return sendeJson(res, 502, { error: 'Zu viele Weiterleitungen.' }); }
if (!antwort || !antwort.ok) {
return sendeJson(res, antwort ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
freigeben();
return sendeJson(res, antwort && antwort.status >= 400 ? antwort.status : 502, { error: 'Download fehlgeschlagen.' });
}
const laenge = Number(antwort.headers.get('content-length')) || 0;
if (MAX_MB > 0 && laenge > MAX_MB * 1024 * 1024) {
const grenze = MAX_MB > 0 ? MAX_MB * 1024 * 1024 : 0;
if (grenze && laenge > grenze) {
freigeben();
return sendeJson(res, 413, { error: `Datei größer als erlaubt (${MAX_MB} MB).` });
}
@@ -197,7 +230,23 @@ async function dateiDurchreichen(req, res, url) {
'Content-Type': antwort.headers.get('content-type') || 'application/octet-stream',
...(laenge ? { 'Content-Length': String(laenge) } : {}),
});
Readable.fromWeb(antwort.body).pipe(res);
// Auch ohne Content-Length (chunked) mitzählen und bei Überschreitung abbrechen,
// sonst ließe sich die Größengrenze umgehen
let gesamt = 0;
const zaehlen = new Transform({
transform(stueck, _enc, fertig) {
gesamt += stueck.length;
if (grenze && gesamt > grenze) return fertig(new Error('Datei größer als erlaubt'));
fertig(null, stueck);
},
});
// pipeline räumt auf, wenn eine Seite abbricht (statt wie pipe() offen zu bleiben)
pipeline(Readable.fromWeb(antwort.body), zaehlen, res, () => freigeben());
} catch (err) {
freigeben();
throw err;
}
}
// ---------------------------------------------------------------------------
@@ -211,7 +260,11 @@ const server = http.createServer(async (req, res) => {
return sendeJson(res, 200, { ok: true, dienst: 'aeromc-cf-proxy' });
}
if (!tokenOk(req)) return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
if (!tokenOk(req)) {
// Fehlversuche je Absender begrenzen, damit sich das Token nicht in Ruhe erraten lässt
if (!darfNoch('falsch:' + absender(req))) return sendeJson(res, 429, { error: 'Zu viele Anfragen, bitte kurz warten.' });
return sendeJson(res, 401, { error: 'Nicht berechtigt.' });
}
/*
* Hilfe beim Einstellen von TRUST_PROXY: zeigt, was der Dienst von DIESER
@@ -249,6 +302,16 @@ const server = http.createServer(async (req, res) => {
* niemals den Schlüssel selbst.
*/
if (url.pathname === '/selftest') {
/*
* Das Token steckt im Launcher und ist auslesbar. Jeder Selbsttest kostet fünf
* CurseForge-Anfragen und läuft vor der Ratenbegrenzung – ohne Bremse ließe sich
* damit das Kontingent des Schlüssels aufbrauchen. Deshalb höchstens alle
* 30 Sekunden wirklich ausführen, dazwischen das letzte Ergebnis zeigen.
*/
const jetzt = Date.now();
if (selftestErgebnis && jetzt - selftestZeit < SELFTEST_ABSTAND_MS) {
return sendeJson(res, 200, Object.assign({}, selftestErgebnis, { zwischengespeichert: true }));
}
const roh = process.env.CF_API_KEY || '';
const befund = {
schluessel: {
@@ -280,6 +343,7 @@ const server = http.createServer(async (req, res) => {
method: p.method,
headers: cfKopfzeilen(p.body ? { 'Content-Type': 'application/json' } : undefined),
body: p.body ? JSON.stringify(p.body) : undefined,
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
let text = '';
try { text = (await a.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
@@ -312,6 +376,8 @@ const server = http.createServer(async (req, res) => {
// Kurzfassung für den schnellen Blick
befund.kurz = befund.proben.map((p) => `${p.name}: ${p.status ?? p.fehler}`);
selftestErgebnis = befund;
selftestZeit = Date.now();
return sendeJson(res, 200, befund);
}