Modliste: melden was sich geaendert hat, einzeln laden
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher gab es fuer Mods nur einen Link auf das Sammelpaket des Hosters — vier
Gigabyte, auch wenn sich zwei Dateien geaendert haben. Jetzt liest der Bot die
Modliste aus der Statusabfrage, die er ohnehin holt, und meldet im Kanal, was
neu, aktualisiert oder entfernt wurde. Beim ersten Durchlauf bleibt es still,
sonst kaeme eine Meldung ueber 110 "neue" Mods.

Geaendert heisst: andere Version oder anderer Hash. Beides einzeln reicht
nicht — manche Modder bessern nach, ohne die Version zu erhoehen.

Dazu die Seite /mods/<server>: jeder Mod mit Version und Groesse, Suche ueber
Titel, Dateiname und Autor, und ein Download je Zeile. Der laeuft durch den
Bot, damit der Zugangs-Code des Spielservers nicht in einem Link landet — mit
dem Code liesse sich auch der ganze Spielstand lesen. Angefragt wird nur, was
wirklich in der Modliste steht; ueber den Dateinamen kommt man an nichts
anderes heran.

Groessen lernt der Bot beim Weiterleiten. Der Spielserver beantwortet kein
HEAD (501) und ignoriert Range, es gibt also keinen billigen Weg, sie vorher
zu erfahren — und 4 GB nur fuers Anzeigen zu holen waere keiner.

Nebenbei gefunden und behoben: 'server.mods' und 'commands.tag' gab es je
zweimal in den Sprachdateien. Der spaetere Eintrag gewinnt still, deshalb
stand auf dem neuen Knopf woertlich "%s Mods (110)" und auf der Befehlsseite
die Beschreibung von /tag statt der Kopfzeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:22:55 +02:00
co-authored by Claude Opus 5
parent 9b681754fa
commit 1ef06f747c
12 changed files with 527 additions and 10 deletions
+72
View File
@@ -27,12 +27,14 @@ import {
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage,
lsState, lsModSizes, setLsModSize,
} from '../db.js';
import crypto from 'node:crypto';
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js';
import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js';
import { istLsServer, modBasis } from '../bot/ls-farm.js';
import { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js';
import {
@@ -226,6 +228,9 @@ export function registerApiRoutes(app, client) {
address: s.address || null,
connect_url: s.connect_url || null,
mods_url: s.mods_url || null,
// Wie viele Mods der Bot von diesem Server kennt — nur dann
// lohnt der Weg auf die Mod-Seite. null heisst „gibt es nicht".
mod_liste: lsState(s.id)?.mods?.length || null,
links: s.links ?? [],
zugang: zugangStand(s, r ?? {}),
// Wie der Server sich im Spiel nennt, falls anders als hier
@@ -1646,6 +1651,73 @@ ${rssItems}
return { servers: listGameservers() };
});
// --- Modliste eines LS-Servers (öffentlich) ---
//
// Die Liste steht in der Statusabfrage, die der LS-Tick ohnehin holt; hier
// kommt sie aus der Datenbank statt bei jedem Seitenaufruf vom Spielserver.
app.get('/api/servers/:id/mods', async (request, reply) => {
const server = getGameserver(Number(request.params.id));
if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' });
const stand = lsState(server.id);
const groessen = lsModSizes(server.id);
return {
server: { id: server.id, name: server.name, spiel: spielName(server.type) },
geprueft: stand?.geprueft ?? null,
mods: (stand?.mods ?? []).map((m) => ({
// Der Hash ist der Fingerabdruck des Spiels und hat auf einer
// öffentlichen Seite nichts verloren — er sagt nichts, was ein
// Besucher braucht, und lädt zum Fehlschluss ein, er sei eine
// Prüfsumme der Datei.
name: m.name, titel: m.titel, autor: m.autor, version: m.version,
bytes: groessen[m.name] ?? null,
})),
};
});
// Download durch den Bot hindurch. Der Zugangs-Code des Spielservers bleibt
// damit hier und steht nicht in einem Link, den jemand weitergibt — mit dem
// Code liesse sich sonst auch der ganze Spielstand lesen.
app.get('/api/servers/:id/mods/:datei', async (request, reply) => {
const server = getGameserver(Number(request.params.id));
if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' });
// Nur Namen, die wirklich in der Modliste stehen. Das ist keine
// Formatprüfung, sondern eine Positivliste: ohne sie liesse sich über
// den Dateinamen jede beliebige Adresse des Spielservers abrufen.
const name = String(request.params.datei ?? '').replace(/\.zip$/i, '');
const stand = lsState(server.id);
const mod = (stand?.mods ?? []).find((m) => m.name === name);
if (!mod) return reply.code(404).send({ error: 'Mod nicht in der Liste' });
const ziel = `${modBasis(server)}/mods/${encodeURIComponent(name)}.zip`
+ `?code=${encodeURIComponent(server.token)}`;
let quelle;
try {
quelle = await fetch(ziel, { signal: AbortSignal.timeout(30_000) });
} catch {
return reply.code(502).send({ error: 'Spielserver antwortet nicht' });
}
if (!quelle.ok || !quelle.body) {
// 403 heisst in aller Regel: gekauftes DLC, das der Server nicht
// weitergeben darf. Das ist kein Fehler, sondern eine Auskunft.
const code = quelle.status === 403 ? 403 : 502;
return reply.code(code).send({
error: code === 403 ? 'Gekaufter Inhalt — über Steam installieren' : 'Download fehlgeschlagen',
});
}
// Grösse merken: der Spielserver beantwortet kein HEAD, also ist das
// die einzige Gelegenheit, sie ohne zusätzlichen Verkehr zu erfahren.
const laenge = Number(quelle.headers.get('content-length'));
if (Number.isFinite(laenge) && laenge > 0) {
setLsModSize(server.id, name, laenge);
reply.header('content-length', String(laenge));
}
reply.header('content-type', 'application/zip');
reply.header('content-disposition', `attachment; filename="${name}.zip"`);
return reply.send(quelle.body);
});
// Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht
// an einer von Hand gepflegten Liste hängt und jedes Update automatisch
// neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port