mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen
- Gemeinsame Zugänge mit Rollen: Admin darf alles, Mitglied nutzt Kalender, Fahrtplaner, Treffpunkte und bearbeitet Mitglieder (kein Hinzufügen, Löschen, Import, keine Patches, keine Statistik-Korrektur) - Server prüft die Rechte (403), Konten ohne Rolle gelten als Admin - npm run user: add <name> [admin|mitglied], role <name> <rolle> - Neue öffentliche Seite Kooperationen mit Titelbild, Anführer, Social-Media-Links und Beschreibung, Pflege durch Admin - Kopfzeile bleibt bei mittlerer Breite einzeilig - Texte auf Wunsch gekürzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f46016487d
commit
819219fb2d
18 files changed
+661
-106
No files matched your search
@@ -3,18 +3,19 @@ import { randomUUID, createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, extname, normalize, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { authRoutes, requireAuth } from './auth.js';
|
||||
import { authRoutes, requireAuth, requireAdmin } from './auth.js';
|
||||
|
||||
const ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = join(ROOT, 'data');
|
||||
const DB_FILE = join(DATA_DIR, 'db.json');
|
||||
const PATCH_DIR = join(DATA_DIR, 'patches');
|
||||
const KOOP_DIR = join(DATA_DIR, 'kooperationen');
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = process.env.HOST; // optional, z. B. 127.0.0.1, damit nur ein Proxy auf demselben Rechner zugreifen kann
|
||||
|
||||
// ---------- Datenspeicher (JSON-Datei) ----------
|
||||
function loadDb() {
|
||||
if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [], patches: [] };
|
||||
if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [], patches: [], cooperations: [] };
|
||||
const db = JSON.parse(readFileSync(DB_FILE, 'utf8'));
|
||||
const members = db.members || [];
|
||||
// Früher war die manuelle Korrektur eine einzelne Zahl – sie gilt für das laufende Jahr
|
||||
@@ -30,6 +31,7 @@ function loadDb() {
|
||||
statsArchive: db.statsArchive || [],
|
||||
// Ältere Patches haben keinen Status – die gab es schon
|
||||
patches: (db.patches || []).map((p) => ({ status: 'vorhanden', ...p })),
|
||||
cooperations: db.cooperations || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,15 +136,44 @@ function cleanPatch(body) {
|
||||
return { name: name.slice(0, 80), description: description.slice(0, 500), status };
|
||||
}
|
||||
|
||||
function savePatchImage({ buf, ext }) {
|
||||
mkdirSync(PATCH_DIR, { recursive: true });
|
||||
// Hochgeladene Bilder bekommen einen zufälligen Namen (kein Raten, keine Pfadtricks)
|
||||
function saveImage(dir, { buf, ext }) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = `${randomUUID()}.${ext}`;
|
||||
writeFileSync(join(PATCH_DIR, file), buf);
|
||||
writeFileSync(join(dir, file), buf);
|
||||
return file;
|
||||
}
|
||||
|
||||
function removePatchImage(file) {
|
||||
try { unlinkSync(join(PATCH_DIR, file)); } catch { /* schon weg */ }
|
||||
function removeImage(dir, file) {
|
||||
if (!file) return;
|
||||
try { unlinkSync(join(dir, file)); } catch { /* schon weg */ }
|
||||
}
|
||||
|
||||
// ---------- Kooperationen ----------
|
||||
const SOCIALS = ['website', 'instagram', 'youtube', 'tiktok', 'facebook', 'discord'];
|
||||
|
||||
function cleanUrl(value, label) {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return null;
|
||||
// "instagram.com/xyz" ohne https:// ist bequem – wird ergänzt
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
||||
let url;
|
||||
try { url = new URL(withScheme); } catch { throw new BadRequest(`${label}: keine gültige Adresse.`); }
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new BadRequest(`${label}: nur http(s)-Adressen.`);
|
||||
return url.href.slice(0, 300);
|
||||
}
|
||||
|
||||
function cleanCooperation(body) {
|
||||
const name = String(body?.name ?? '').trim();
|
||||
if (!name) throw new BadRequest('Name der Gruppe fehlt.');
|
||||
const leader = String(body.leader ?? '').trim();
|
||||
const description = String(body.description ?? '').trim();
|
||||
const socials = {};
|
||||
for (const key of SOCIALS) {
|
||||
const url = cleanUrl(body.socials?.[key], key[0].toUpperCase() + key.slice(1));
|
||||
if (url) socials[key] = url;
|
||||
}
|
||||
return { name: name.slice(0, 80), leader: leader.slice(0, 120), description: description.slice(0, 2000), socials };
|
||||
}
|
||||
|
||||
function cleanMeetingPoint(body) {
|
||||
@@ -225,7 +256,7 @@ app.disable('x-powered-by');
|
||||
// Im Docker-Container kommt nginx über das Docker-Netz → TRUST_PROXY="loopback, uniquelocal"
|
||||
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
|
||||
// Patch-Uploads enthalten ein Bild, alles andere bleibt klein
|
||||
app.use('/api/patches', express.json({ limit: '12mb' }));
|
||||
app.use(['/api/patches', '/api/kooperationen'], express.json({ limit: '12mb' }));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
// Öffentlich: Startseite, Login, CSS/Bilder
|
||||
@@ -233,8 +264,8 @@ app.use(versioned(join(ROOT, 'public'), 'no-cache'));
|
||||
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
|
||||
authRoutes(app);
|
||||
|
||||
// Patch-Bilder sind öffentlich (Dateinamen sind zufällige IDs)
|
||||
app.use('/patch-img', express.static(PATCH_DIR, {
|
||||
// Hochgeladene Bilder sind öffentlich (Dateinamen sind zufällige IDs)
|
||||
const imageStatic = (dir) => express.static(dir, {
|
||||
maxAge: '30d',
|
||||
immutable: true,
|
||||
setHeaders: (res, path) => {
|
||||
@@ -242,7 +273,9 @@ app.use('/patch-img', express.static(PATCH_DIR, {
|
||||
// SVG direkt aufgerufen: keine Skripte, keine fremden Inhalte
|
||||
if (path.endsWith('.svg')) res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox");
|
||||
},
|
||||
}));
|
||||
});
|
||||
app.use('/patch-img', imageStatic(PATCH_DIR));
|
||||
app.use('/koop-img', imageStatic(KOOP_DIR));
|
||||
|
||||
// Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen
|
||||
function todayIso() {
|
||||
@@ -316,12 +349,18 @@ app.get('/api/patches', (req, res) => {
|
||||
res.json(sortedPatches());
|
||||
});
|
||||
|
||||
const sortedCooperations = () => [...db.cooperations].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
|
||||
app.get('/api/kooperationen', (req, res) => {
|
||||
res.json(sortedCooperations());
|
||||
});
|
||||
|
||||
// Ab hier nur mit Login
|
||||
app.use(requireAuth);
|
||||
|
||||
app.post('/api/patches', (req, res) => {
|
||||
app.post('/api/patches', requireAdmin, (req, res) => {
|
||||
const fields = cleanPatch(req.body);
|
||||
const image = savePatchImage(cleanImage(req.body.image));
|
||||
const image = saveImage(PATCH_DIR, cleanImage(req.body.image));
|
||||
const patch = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
|
||||
db.patches.push(patch);
|
||||
saveDb();
|
||||
@@ -329,27 +368,63 @@ app.post('/api/patches', (req, res) => {
|
||||
});
|
||||
|
||||
// Bild ist optional – ohne Bild werden nur Name und Beschreibung geändert
|
||||
app.put('/api/patches/:id', (req, res) => {
|
||||
app.put('/api/patches/:id', requireAdmin, (req, res) => {
|
||||
const patch = findOr404(db.patches, req.params.id, res, 'Patch');
|
||||
if (!patch) return;
|
||||
const fields = cleanPatch(req.body);
|
||||
if (req.body.image) {
|
||||
const old = patch.image;
|
||||
patch.image = savePatchImage(cleanImage(req.body.image));
|
||||
removePatchImage(old);
|
||||
patch.image = saveImage(PATCH_DIR, cleanImage(req.body.image));
|
||||
removeImage(PATCH_DIR, old);
|
||||
}
|
||||
Object.assign(patch, fields);
|
||||
saveDb();
|
||||
res.json(patch);
|
||||
});
|
||||
|
||||
app.delete('/api/patches/:id', (req, res) => {
|
||||
app.delete('/api/patches/:id', requireAdmin, (req, res) => {
|
||||
const patch = db.patches.find((p) => p.id === req.params.id);
|
||||
if (patch) removePatchImage(patch.image);
|
||||
if (patch) removeImage(PATCH_DIR, patch.image);
|
||||
db.patches = db.patches.filter((p) => p.id !== req.params.id);
|
||||
saveDb();
|
||||
res.status(204).end();
|
||||
});
|
||||
// Kooperationen: anlegen, ändern, löschen nur Admin. Titelbild ist optional.
|
||||
app.post('/api/kooperationen', requireAdmin, (req, res) => {
|
||||
const fields = cleanCooperation(req.body);
|
||||
const image = req.body.image ? saveImage(KOOP_DIR, cleanImage(req.body.image)) : null;
|
||||
const coop = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
|
||||
db.cooperations.push(coop);
|
||||
saveDb();
|
||||
res.status(201).json(coop);
|
||||
});
|
||||
|
||||
// image: neue data:-URL ersetzt das Bild, removeImage: true entfernt es, sonst bleibt es
|
||||
app.put('/api/kooperationen/:id', requireAdmin, (req, res) => {
|
||||
const coop = findOr404(db.cooperations, req.params.id, res, 'Kooperation');
|
||||
if (!coop) return;
|
||||
const fields = cleanCooperation(req.body);
|
||||
if (req.body.image) {
|
||||
const old = coop.image;
|
||||
coop.image = saveImage(KOOP_DIR, cleanImage(req.body.image));
|
||||
removeImage(KOOP_DIR, old);
|
||||
} else if (req.body.removeImage) {
|
||||
removeImage(KOOP_DIR, coop.image);
|
||||
coop.image = null;
|
||||
}
|
||||
Object.assign(coop, fields);
|
||||
saveDb();
|
||||
res.json(coop);
|
||||
});
|
||||
|
||||
app.delete('/api/kooperationen/:id', requireAdmin, (req, res) => {
|
||||
const coop = db.cooperations.find((c) => c.id === req.params.id);
|
||||
if (coop) removeImage(KOOP_DIR, coop.image);
|
||||
db.cooperations = db.cooperations.filter((c) => c.id !== req.params.id);
|
||||
saveDb();
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
app.use(versioned(join(ROOT, 'private'), 'private, no-cache'));
|
||||
app.use(express.static(join(ROOT, 'private'), {
|
||||
extensions: ['html'],
|
||||
@@ -361,7 +436,7 @@ app.get('/api/members', (req, res) => {
|
||||
res.json(db.members);
|
||||
});
|
||||
|
||||
app.post('/api/members', (req, res) => {
|
||||
app.post('/api/members', requireAdmin, (req, res) => {
|
||||
const member = { id: randomUUID(), ...cleanMember(req.body, { memberIds: memberIds() }) };
|
||||
db.members.push(member);
|
||||
saveDb();
|
||||
@@ -376,7 +451,7 @@ app.put('/api/members/:id', (req, res) => {
|
||||
res.json(member);
|
||||
});
|
||||
|
||||
app.delete('/api/members/:id', (req, res) => {
|
||||
app.delete('/api/members/:id', requireAdmin, (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.members = db.members.filter((m) => m.id !== id);
|
||||
for (const m of db.members) if (m.ridesWith === id) m.ridesWith = null;
|
||||
@@ -386,7 +461,7 @@ app.delete('/api/members/:id', (req, res) => {
|
||||
});
|
||||
|
||||
// Manuelle Korrektur der gespielten Spiele (z. B. spontane Fahrten, die nicht im Kalender stehen)
|
||||
app.put('/api/members/:id/plays-manual', (req, res) => {
|
||||
app.put('/api/members/:id/plays-manual', requireAdmin, (req, res) => {
|
||||
const member = findOr404(db.members, req.params.id, res, 'Mitglied');
|
||||
if (!member) return;
|
||||
const year = new Date().getFullYear();
|
||||
@@ -396,7 +471,7 @@ app.put('/api/members/:id/plays-manual', (req, res) => {
|
||||
});
|
||||
|
||||
// Ersetzt die komplette Gruppe (Import aus JSON-Datei)
|
||||
app.post('/api/members/import', (req, res) => {
|
||||
app.post('/api/members/import', requireAdmin, (req, res) => {
|
||||
if (!Array.isArray(req.body)) throw new BadRequest('Erwarte eine Liste von Mitgliedern.');
|
||||
const rows = req.body
|
||||
.filter((m) => String(m?.name ?? '').trim())
|
||||
|
||||
Reference in new issue
Block a user