Upload via GUI (28 Dateien)
This commit is contained in:
+314
-7
@@ -236,6 +236,7 @@ const INHALTSARTEN = {
|
||||
mod: { projectType: 'mod', ordner: 'mods', label: 'Mods', loaderNoetig: true },
|
||||
resourcepack: { projectType: 'resourcepack', ordner: 'resourcepacks', label: 'Ressourcenpakete', loaderNoetig: false },
|
||||
shader: { projectType: 'shader', ordner: 'shaderpacks', label: 'Shader', loaderNoetig: false },
|
||||
modpack: { projectType: 'modpack', ordner: null, label: 'Modpacks', loaderNoetig: false },
|
||||
};
|
||||
|
||||
function inhaltsArt(art) {
|
||||
@@ -270,8 +271,12 @@ async function modrinthSearch({ query = '', gameVersion = '', loader = '', categ
|
||||
description: h.description,
|
||||
author: h.author,
|
||||
downloads: h.downloads,
|
||||
follows: h.follows || 0,
|
||||
iconUrl: h.icon_url || '',
|
||||
categories: h.categories || [],
|
||||
// bei Modpacks hilfreich: welche Spielversionen/Loader das Projekt angibt
|
||||
versions: h.versions || [],
|
||||
displayCategories: h.display_categories || h.categories || [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -302,6 +307,38 @@ async function modrinthResolveFile({ projectId, gameVersion, loader, art = 'mod'
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Neueste passende Modpack-Version von Modrinth.
|
||||
* gameVersion optional – ohne Filter kommt die neueste Version.
|
||||
*/
|
||||
async function modrinthResolveModpack({ projectId, gameVersion = '' } = {}) {
|
||||
const url = new URL(MODRINTH + '/project/' + projectId + '/version');
|
||||
if (gameVersion) url.searchParams.set('game_versions', JSON.stringify([gameVersion]));
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('Modpack-Versionen fehlgeschlagen: HTTP ' + res.status);
|
||||
const versions = await res.json();
|
||||
if (!Array.isArray(versions) || !versions.length) return null;
|
||||
const v = versions[0];
|
||||
const file = (v.files || []).find((f) => f.primary) || (v.files || [])[0];
|
||||
if (!file || !file.url) return null;
|
||||
return {
|
||||
versionId: v.id,
|
||||
versionNumber: v.version_number,
|
||||
name: v.name || v.version_number,
|
||||
filename: file.filename,
|
||||
url: file.url,
|
||||
size: file.size || 0,
|
||||
gameVersions: v.game_versions || [],
|
||||
loaders: v.loaders || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadBuffer(url, extraHeaders) {
|
||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||
if (!res.ok) throw new Error('Download fehlgeschlagen: HTTP ' + res.status);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
// extraHeaders: z. B. der CurseForge-API-Key, den das CDN seit Juli 2026 verlangt
|
||||
async function downloadFile(url, destPath, extraHeaders) {
|
||||
const res = await fetch(url, { headers: Object.assign({ 'User-Agent': UA }, extraHeaders || {}) });
|
||||
@@ -408,16 +445,42 @@ const STANDARD_TOKEN = String(BUILD_VORGABEN.curseforgeProxyToken || '');
|
||||
* kein fremdes Kontingent.
|
||||
* -> { modus, basis, kopfzeilen, downloadAdresse(url) } oder null
|
||||
*/
|
||||
/*
|
||||
* CurseForge kennt zwei Schlüsselarten:
|
||||
* - Klassisch (console.curseforge.com): Header x-api-key, oft $2a$10$…
|
||||
* - Studios / Profil-PAT: Header "Authorization: Key cfc_pat_…"
|
||||
*/
|
||||
function cfAuthHeaders(apiKey) {
|
||||
let key = String(apiKey || '').trim();
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
key = key.slice(1, -1).trim();
|
||||
}
|
||||
key = key.replace(/[\r\n\t]/g, '').trim();
|
||||
// Komplette Kopfzeile eingefügt?
|
||||
const m = key.match(/^authorization\s*:\s*key\s+(.+)$/i);
|
||||
if (m) key = m[1].trim();
|
||||
if (key.toLowerCase().startsWith('key ')) key = key.slice(4).trim();
|
||||
|
||||
if (!key) return null;
|
||||
|
||||
// Neues Studios-Format
|
||||
if (/^cfc_pat_/i.test(key) || /^cf[a-z]_/i.test(key)) {
|
||||
return { key, headers: { Authorization: 'Key ' + key } };
|
||||
}
|
||||
// Klassisch
|
||||
return { key, headers: { 'x-api-key': key } };
|
||||
}
|
||||
|
||||
function curseforgeZugang(settings) {
|
||||
const s = settings || {};
|
||||
const key = String(s.curseforgeApiKey || '').trim();
|
||||
if (key) {
|
||||
const auth = cfAuthHeaders(s.curseforgeApiKey);
|
||||
if (auth) {
|
||||
return {
|
||||
modus: 'direkt',
|
||||
basis: CURSEFORGE,
|
||||
kopfzeilen: { 'x-api-key': key },
|
||||
kopfzeilen: auth.headers,
|
||||
downloadAdresse: (url) => url,
|
||||
downloadKopfzeilen: { 'x-api-key': key },
|
||||
downloadKopfzeilen: auth.headers,
|
||||
};
|
||||
}
|
||||
const eigeneAdresse = String(s.curseforgeProxyUrl || '').trim().replace(/\/+$/, '');
|
||||
@@ -451,6 +514,250 @@ function forgeCdnUrl(fileId, fileName) {
|
||||
return `https://edge.forgecdn.net/files/${s.slice(0, 4)}/${Number(s.slice(4))}/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
const CF_LOADER = { forge: 1, fabric: 4, quilt: 5, neoforge: 6 };
|
||||
const CF_CLASS = { mod: 6, resourcepack: 12, shader: 6552 };
|
||||
|
||||
function cfLoaderType(loader) {
|
||||
return CF_LOADER[String(loader || '').toLowerCase()] || 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* CurseForge-Katalogsuche. Braucht direkten API-Key (modus === 'direkt').
|
||||
* Die Weiterleitung deckt absichtlich nur Dateiauflösung und Download ab.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Prüft einen API-Key direkt gegen CurseForge (ohne den gespeicherten Stand).
|
||||
* Drei Proben: games, Minecraft, Modsuche – damit man sieht, ob der Key
|
||||
* grundsätzlich gilt oder nur für bestimmte Bereiche fehlt.
|
||||
*/
|
||||
async function curseforgeTestKey(apiKey) {
|
||||
const auth = cfAuthHeaders(apiKey);
|
||||
if (!auth) {
|
||||
return { ok: false, message: 'Kein API-Key angegeben.' };
|
||||
}
|
||||
const key = auth.key;
|
||||
const headers = Object.assign({ Accept: 'application/json', 'User-Agent': UA }, auth.headers);
|
||||
const proben = [
|
||||
{ name: 'games', url: CURSEFORGE + '/games' },
|
||||
{ name: 'minecraft', url: CURSEFORGE + '/games/432' },
|
||||
{ name: 'suche', url: CURSEFORGE + '/mods/search?gameId=432&classId=6&pageSize=1&index=0' },
|
||||
];
|
||||
const ergebnisse = [];
|
||||
for (const p of proben) {
|
||||
try {
|
||||
const res = await fetch(p.url, { headers });
|
||||
let text = '';
|
||||
try { text = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 120); } catch { /* egal */ }
|
||||
ergebnisse.push({ name: p.name, status: res.status, ok: res.ok, text: res.ok ? 'ok' : text });
|
||||
} catch (err) {
|
||||
ergebnisse.push({ name: p.name, status: 0, ok: false, text: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const alleOk = ergebnisse.every((e) => e.ok);
|
||||
const alle403 = ergebnisse.every((e) => e.status === 401 || e.status === 403);
|
||||
let message;
|
||||
if (alleOk) {
|
||||
message = 'Schlüssel wird akzeptiert – Katalog und Downloads sollten funktionieren.';
|
||||
} else if (alle403) {
|
||||
message = 'CurseForge lehnt diesen Schlüssel ab (HTTP 403/401). Entweder ist er falsch kopiert, '
|
||||
+ 'wurde neu erzeugt („Regenerate“ macht den alten ungültig), oder das Organisationskonto '
|
||||
+ 'ist bei CurseForge noch nicht für die API freigeschaltet.';
|
||||
} else {
|
||||
const suche = ergebnisse.find((e) => e.name === 'suche');
|
||||
if (suche && !suche.ok && ergebnisse.filter((e) => e.name !== 'suche').every((e) => e.ok)) {
|
||||
message = 'Der Schlüssel gilt grundsätzlich, aber die Modsuche wird abgelehnt. '
|
||||
+ 'Für den Katalog wird der Organisationsschlüssel benötigt (nicht nur ein per-game Key).';
|
||||
} else {
|
||||
message = 'Gemischtes Ergebnis: ' + ergebnisse.map((e) => e.name + '=' + e.status).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: alleOk,
|
||||
message,
|
||||
laenge: key.length,
|
||||
beginntMit: key.slice(0, 7),
|
||||
siehtAusWieCurseForge: /^\$2[aby]\$\d{2}\$/.test(key) || /^cfc_pat_/i.test(key),
|
||||
format: /^cfc_pat_/i.test(key) ? 'studios-pat' : (/^\$2[aby]\$/.test(key) ? 'klassisch' : 'unbekannt'),
|
||||
proben: ergebnisse,
|
||||
};
|
||||
}
|
||||
|
||||
async function curseforgeSearch(zugang, {
|
||||
query = '', gameVersion = '', loader = '', sort = 'popularity',
|
||||
limit = 30, offset = 0, art = 'mod',
|
||||
} = {}) {
|
||||
if (!zugang || zugang.modus !== 'direkt') {
|
||||
return {
|
||||
total: 0,
|
||||
hits: [],
|
||||
needKey: true,
|
||||
error: 'Für die CurseForge-Suche brauchst du einen eigenen API-Key in den Netzwerk-Einstellungen. '
|
||||
+ 'Die Weiterleitung reicht für Downloads, nicht für den Katalog.',
|
||||
};
|
||||
}
|
||||
const classId = CF_CLASS[art] || CF_CLASS.mod;
|
||||
const url = new URL(CURSEFORGE + '/mods/search');
|
||||
url.searchParams.set('gameId', '432');
|
||||
url.searchParams.set('classId', String(classId));
|
||||
if (query) url.searchParams.set('searchFilter', query);
|
||||
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
|
||||
const lt = cfLoaderType(loader);
|
||||
// Loader-Filter nur bei Mods sinnvoll
|
||||
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
|
||||
// sortField: 1=Featured 2=Popularity 3=LastUpdated 4=Name 5=Author 6=TotalDownloads
|
||||
const sortMap = { popularity: 2, relevance: 2, downloads: 6, updated: 3, newest: 3, follows: 2 };
|
||||
url.searchParams.set('sortField', String(sortMap[sort] || 2));
|
||||
url.searchParams.set('sortOrder', 'desc');
|
||||
url.searchParams.set('pageSize', String(Math.min(50, limit || 30)));
|
||||
url.searchParams.set('index', String(offset || 0));
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: Object.assign({
|
||||
Accept: 'application/json',
|
||||
'User-Agent': UA,
|
||||
}, zugang.kopfzeilen),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try { detail = (await res.text()).replace(/\s+/g, ' ').trim().slice(0, 160); } catch { /* egal */ }
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return {
|
||||
total: 0,
|
||||
hits: [],
|
||||
needKey: true,
|
||||
error: 'CurseForge lehnt den API-Key ab (HTTP ' + res.status + '). '
|
||||
+ 'Bitte unter Optionen → Netzwerk einen gültigen Schlüssel von console.curseforge.com eintragen. '
|
||||
+ 'Das Proxy-Token ist kein API-Key.'
|
||||
+ (detail ? ' (' + detail + ')' : ''),
|
||||
};
|
||||
}
|
||||
throw new Error('CurseForge-Suche fehlgeschlagen: HTTP ' + res.status + (detail ? ' – ' + detail : ''));
|
||||
}
|
||||
const data = await res.json();
|
||||
const hits = (data.data || []).map((m) => {
|
||||
const author = (m.authors && m.authors[0] && m.authors[0].name) || '';
|
||||
const logo = (m.logo && (m.logo.thumbnailUrl || m.logo.url)) || '';
|
||||
return {
|
||||
projectId: 'cf-' + m.id,
|
||||
cfId: m.id,
|
||||
slug: m.slug,
|
||||
title: m.name,
|
||||
description: m.summary || '',
|
||||
author,
|
||||
downloads: m.downloadCount || 0,
|
||||
iconUrl: logo,
|
||||
categories: (m.categories || []).map((c) => c.name).filter(Boolean),
|
||||
source: 'curseforge',
|
||||
};
|
||||
});
|
||||
const total = (data.pagination && data.pagination.totalCount) || hits.length;
|
||||
return { total, hits };
|
||||
}
|
||||
|
||||
function cfFileMatches(file, gameVersion, loader, art) {
|
||||
if (!file || file.isAvailable === false) return false;
|
||||
const gvs = file.gameVersions || [];
|
||||
if (gameVersion && !gvs.some((v) => String(v).toLowerCase() === String(gameVersion).toLowerCase())) {
|
||||
// manche Einträge trennen Version und Loader in gameVersions
|
||||
const hasVer = gvs.some((v) => String(v) === gameVersion);
|
||||
if (!hasVer) return false;
|
||||
}
|
||||
if (art === 'mod' && loader && loader !== 'vanilla') {
|
||||
const want = String(loader).toLowerCase();
|
||||
const ok = gvs.some((v) => String(v).toLowerCase() === want)
|
||||
|| (file.sortableGameVersions || []).some((s) => {
|
||||
const name = String(s.gameVersionName || s.gameVersion || '').toLowerCase();
|
||||
return name === want;
|
||||
});
|
||||
// Wenn gar kein Loader in den Tags steht, trotzdem erlauben (manche Dateien)
|
||||
const anyLoader = gvs.some((v) => /fabric|forge|quilt|neoforge/i.test(String(v)))
|
||||
|| (file.sortableGameVersions || []).some((s) => /fabric|forge|quilt|neoforge/i.test(String(s.gameVersionName || '')));
|
||||
if (anyLoader && !ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function curseforgePickFile(zugang, modId, gameVersion, loader, art) {
|
||||
if (!zugang || zugang.modus !== 'direkt') {
|
||||
const err = new Error('CurseForge-Dateiauswahl braucht einen eigenen API-Key.');
|
||||
err.code = 'need-key';
|
||||
throw err;
|
||||
}
|
||||
const url = new URL(CURSEFORGE + '/mods/' + modId + '/files');
|
||||
url.searchParams.set('pageSize', '50');
|
||||
url.searchParams.set('index', '0');
|
||||
if (gameVersion) url.searchParams.set('gameVersion', gameVersion);
|
||||
const lt = cfLoaderType(loader);
|
||||
if (art === 'mod' && lt) url.searchParams.set('modLoaderType', String(lt));
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: Object.assign({ Accept: 'application/json', 'User-Agent': UA }, zugang.kopfzeilen),
|
||||
});
|
||||
if (!res.ok) throw new Error('CurseForge-Dateien fehlgeschlagen: HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
const files = data.data || [];
|
||||
const match = files.find((f) => cfFileMatches(f, gameVersion, loader, art)) || files[0];
|
||||
if (!match) return null;
|
||||
const name = match.fileName || ('file-' + match.id + '.jar');
|
||||
let dl = match.downloadUrl || null;
|
||||
if (!dl) dl = forgeCdnUrl(match.id, name);
|
||||
return {
|
||||
fileId: match.id,
|
||||
filename: name,
|
||||
url: dl,
|
||||
versionNumber: match.displayName || match.fileName || String(match.id),
|
||||
dependencies: match.dependencies || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function curseforgeInstall({ zugang, projectId, title, gameVersion, loader, modsDir, art = 'mod' }) {
|
||||
if (!zugang) return { ok: false, message: 'Kein CurseForge-Zugang eingerichtet.' };
|
||||
const rawId = String(projectId || '').replace(/^cf-/, '');
|
||||
const modId = Number(rawId);
|
||||
if (!modId) return { ok: false, message: 'Ungültige CurseForge-Projekt-ID.' };
|
||||
|
||||
const file = await curseforgePickFile(zugang, modId, gameVersion, loader, art);
|
||||
if (!file || !file.url) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-match',
|
||||
message: `Keine passende Datei für ${gameVersion || '?'} / ${loader || 'any'} gefunden.`,
|
||||
};
|
||||
}
|
||||
|
||||
const dest = path.join(modsDir, file.filename);
|
||||
// Download: direkt mit Key oder über Weiterleitung
|
||||
const url = zugang.downloadAdresse ? zugang.downloadAdresse(file.url) : file.url;
|
||||
const headers = Object.assign({}, zugang.downloadKopfzeilen || {});
|
||||
const bytes = await downloadFile(url, dest, headers);
|
||||
|
||||
// Pflicht-Abhängigkeiten (relationType 3 = RequiredDependency)
|
||||
const depIds = (file.dependencies || [])
|
||||
.filter((d) => d.relationType === 3 && d.modId)
|
||||
.map((d) => d.modId);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
dependencies: depIds.map((id) => ({ projectId: 'cf-' + id, required: true })),
|
||||
entry: {
|
||||
projectId: 'cf-' + modId,
|
||||
title: title || file.filename,
|
||||
filename: file.filename,
|
||||
versionId: String(file.fileId),
|
||||
versionNumber: file.versionNumber,
|
||||
gameVersion,
|
||||
loader,
|
||||
source: 'curseforge',
|
||||
bytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Löst die Dateien eines CurseForge-Modpacks auf.
|
||||
* zugang: aus curseforgeZugang(); files: [{ projectID, fileID }]
|
||||
@@ -504,7 +811,7 @@ async function modrinthProjectTitle(projectId) {
|
||||
module.exports = {
|
||||
scanJava, probeJava, setCacheDir, setManagedJavaDir, pruefeSpeicher,
|
||||
getMcVersions,
|
||||
modrinthSearch, modrinthResolveFile, modrinthInstall, modrinthProjectTitle,
|
||||
modrinthFindUpdates, downloadFile, inhaltsArt, INHALTSARTEN,
|
||||
curseforgeResolveFiles, forgeCdnUrl, curseforgeZugang,
|
||||
modrinthSearch, modrinthResolveFile, modrinthResolveModpack, modrinthInstall, modrinthProjectTitle,
|
||||
modrinthFindUpdates, downloadFile, downloadBuffer, inhaltsArt, INHALTSARTEN,
|
||||
curseforgeResolveFiles, curseforgeSearch, curseforgeInstall, curseforgeTestKey, forgeCdnUrl, curseforgeZugang,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user