Käfer-Button unten rechts: nimmt den sichtbaren Bildschirm mit html2canvas (selbst gehostet) auf, Vorschau mit roten Rechtecken, Beschreibung, Name optional, Senden ohne Anmeldung. Aus offenen Dialogen heraus wird der Dialog selbst aufgenommen. Bearbeiter sehen die Meldungen unter „Fehler" mit Zähler, kopieren einen fertigen Claude-Prompt und den Screenshot. Serverseitig: Pfade nur in exakter Schreibweise, /api standardmäßig nur für Bearbeiter mit ausdrücklicher Freigabeliste, Bremse zählt jeden Versuch vor dem Lesen großer Bodies, X-Forwarded-For nur von vertrauten Proxys, Nutzertext im Prompt eingezäunt. HTTP-Tests für die Sperren. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
880 lines
38 KiB
JavaScript
880 lines
38 KiB
JavaScript
import express from 'express';
|
||
import path from 'node:path';
|
||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||
import { execSync } from 'node:child_process';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { loadEnvFile } from './env.js';
|
||
import { createStore, imageMime } from './store.js';
|
||
import { looksLikePlaceholder } from './image.js';
|
||
import { createAuth, readCookie, cookieHeader } from './auth.js';
|
||
import { validateRequest, applySwap, REQUEST_STATUS } from './requests.js';
|
||
import { parseScreenshot, buildPrompt, BUG_STATUS, DEFAULT_PROMPT_TEMPLATE } from './bugs.js';
|
||
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
|
||
import { germanHolidays, BUNDESLAENDER } from './holidays.js';
|
||
import { buildEventLines, buildCalendar, eventUid } from './ics.js';
|
||
import { nextcloudConfigFromEnv, createNextcloudClient } from './nextcloud.js';
|
||
import { parseEvents, parseVCards, parseAbsenceSummary, matchPerson, mergeAbsences } from './ical.js';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
loadEnvFile(path.join(__dirname, '..', '.env'));
|
||
|
||
const dataDir = process.env.VCH_DATA_DIR || path.join(__dirname, '..', 'data');
|
||
const store = createStore(dataDir);
|
||
const nc = nextcloudConfigFromEnv();
|
||
if (String(process.env.NEXTCLOUD_INSECURE_TLS).toLowerCase() === 'true') {
|
||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||
console.warn('Warnung: TLS-Zertifikate werden nicht geprüft (NEXTCLOUD_INSECURE_TLS=true).');
|
||
}
|
||
|
||
// Versionskennung für Fehlermeldungen: Commit aus Umgebung oder Git, sonst unbekannt
|
||
let appVersion = (process.env.VCH_COMMIT || process.env.APP_VERSION || '').trim();
|
||
if (!appVersion) {
|
||
try { appVersion = execSync('git rev-parse --short HEAD', { cwd: path.join(__dirname, '..'), stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch { appVersion = 'unbekannt'; }
|
||
}
|
||
|
||
export const app = express();
|
||
app.disable('x-powered-by');
|
||
app.set('etag', false);
|
||
// Pfade nur in exakter Schreibweise zuordnen – sonst liefe /API/… an der Bearbeitungssperre vorbei
|
||
app.set('case sensitive routing', true);
|
||
// Welche Proxys dürfen X-Forwarded-For setzen? Leer = nur localhost, "none" = keiner, sonst Adressen/Netze
|
||
const trustedProxy = (process.env.TRUSTED_PROXY || '').trim();
|
||
app.set('trust proxy', trustedProxy === '' ? 'loopback' : trustedProxy === 'none' ? false : trustedProxy.split(',').map((x) => x.trim()));
|
||
const clientIp = (req) => req.ip || req.socket?.remoteAddress || '?';
|
||
|
||
// Healthcheck ohne Passwort, damit die Plattform den Prüfling erreichen kann
|
||
app.get('/health', (req, res) => res.type('text/plain').send('ok'));
|
||
|
||
// Optionaler Zugriffsschutz
|
||
const appPassword = (process.env.APP_PASSWORD || '').trim();
|
||
if (appPassword) {
|
||
app.use((req, res, next) => {
|
||
const header = req.headers.authorization || '';
|
||
const given = header.startsWith('Basic ') ? Buffer.from(header.slice(6), 'base64').toString('utf8').split(':').slice(1).join(':') : '';
|
||
const a = Buffer.from(given);
|
||
const b = Buffer.from(appPassword);
|
||
if (a.length === b.length && timingSafeEqual(a, b)) return next();
|
||
res.set('WWW-Authenticate', 'Basic realm="Rotatory", charset="UTF-8"');
|
||
res.status(401).send('Zugang nur mit Passwort.');
|
||
});
|
||
}
|
||
|
||
const jsonBody = express.json({ limit: '1mb' });
|
||
const bigJsonBody = express.json({ limit: '9mb' }); // Fehlermeldung mit Screenshot (6 MB Bild als Base64)
|
||
|
||
// Bremse für Fehlermeldungen: jeder Versuch zählt, auch ein später abgelehnter – und zwar bevor der große Body gelesen wird
|
||
const bugHits = new Map(); // ip → [{ t, bytes }]
|
||
const BUG_WINDOW_MS = 3_600_000;
|
||
const BUG_MAX_PER_HOUR = 10;
|
||
const BUG_MAX_BYTES_PER_HOUR = 30 * 1024 * 1024;
|
||
function bugLimited(req) {
|
||
const ip = clientIp(req);
|
||
const now = Date.now();
|
||
const hits = (bugHits.get(ip) || []).filter((h) => now - h.t < BUG_WINDOW_MS);
|
||
const declared = Number(req.headers['content-length']) || 0;
|
||
const bytes = hits.reduce((n, h) => n + h.bytes, 0);
|
||
bugHits.set(ip, hits);
|
||
if (hits.length >= BUG_MAX_PER_HOUR || bytes + declared > BUG_MAX_BYTES_PER_HOUR) return true;
|
||
hits.push({ t: now, bytes: declared });
|
||
return false;
|
||
}
|
||
app.use((req, res, next) => {
|
||
if (req.method !== 'POST' || req.path !== '/api/bugs') return jsonBody(req, res, next);
|
||
if (bugLimited(req)) return res.status(429).json({ error: 'Zu viele Meldungen in kurzer Zeit. Bitte später noch einmal.' });
|
||
return bigJsonBody(req, res, next);
|
||
});
|
||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||
|
||
// ---------- Bearbeitungsschutz ----------
|
||
// Ohne EDIT_PASSWORD darf jeder alles (wie bisher). Mit Passwort sehen Betrachter nur den Kalender.
|
||
const auth = createAuth({ password: process.env.EDIT_PASSWORD, secretFile: path.join(dataDir, 'session-secret') });
|
||
const isSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
|
||
app.use((req, res, next) => {
|
||
req.isEditor = !auth.enabled || auth.verify(readCookie(req.headers.cookie));
|
||
next();
|
||
});
|
||
// Alles unter /api ist für Bearbeiter – außer dieser ausdrücklichen Liste
|
||
const PUBLIC_API = (method, p) => p === '/api/auth' || p.startsWith('/api/auth/')
|
||
|| (method === 'POST' && (p === '/api/requests' || p === '/api/bugs')) // Tauschanfragen und Fehlermeldungen darf jeder stellen
|
||
|| ((method === 'GET' || method === 'HEAD') && (p === '/api/state' || p === '/api/stats' || p === '/api/export.ics'));
|
||
const needsEditor = (req) => req.path.startsWith('/api/') && !PUBLIC_API(req.method, req.path);
|
||
app.use((req, res, next) => {
|
||
if (needsEditor(req) && !req.isEditor) return res.status(403).json({ error: 'Anmeldung erforderlich.', authRequired: true });
|
||
next();
|
||
});
|
||
|
||
app.get('/api/auth', (req, res) => res.json({ protected: auth.enabled, editor: req.isEditor }));
|
||
app.post('/api/auth/login', (req, res) => {
|
||
if (!auth.enabled) return res.json({ protected: false, editor: true });
|
||
const ip = clientIp(req);
|
||
const wait = auth.blockedFor(ip);
|
||
if (wait > 0) return res.status(429).json({ error: `Zu viele Fehlversuche. Bitte ${wait} Sekunden warten.` });
|
||
if (!auth.checkPassword(req.body?.password)) {
|
||
auth.noteFailure(ip);
|
||
return res.status(401).json({ error: 'Passwort stimmt nicht.' });
|
||
}
|
||
auth.noteSuccess(ip);
|
||
res.set('Set-Cookie', cookieHeader(auth.issue(), { secure: isSecure(req) }));
|
||
res.json({ protected: true, editor: true });
|
||
});
|
||
app.post('/api/auth/logout', (req, res) => {
|
||
res.set('Set-Cookie', cookieHeader('', { secure: isSecure(req), maxAgeMs: 0 }));
|
||
res.json({ protected: auth.enabled, editor: !auth.enabled });
|
||
});
|
||
|
||
const S = () => store.state;
|
||
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
|
||
const bad = (res, msg) => res.status(400).json({ error: msg });
|
||
const blockedDays = () => new Set(S().holidays.map((h) => h.date));
|
||
const ABSENCE_TYPES = ['urlaub', 'krankheit', 'frei', 'sonstiges'];
|
||
|
||
// Beim ersten Start: Feiertage für dieses und nächstes Jahr vorbelegen
|
||
if (S().holidays.length === 0 && !S().settings.holidaysSeeded) {
|
||
const year = new Date().getFullYear();
|
||
for (const h of [...germanHolidays(year, S().settings.bundesland), ...germanHolidays(year + 1, S().settings.bundesland)]) S().holidays.push(h);
|
||
S().settings.holidaysSeeded = true;
|
||
store.save();
|
||
}
|
||
|
||
/** Gehört eine Kalender-/Adressbuch-URL zur konfigurierten Nextcloud (gleicher Origin, gleicher Pfadstamm)? */
|
||
function isOurNextcloudUrl(href) {
|
||
if (!nc.baseUrl || !href) return false;
|
||
try {
|
||
const a = new URL(href);
|
||
const b = new URL(nc.baseUrl + '/');
|
||
return a.origin === b.origin && a.pathname.startsWith(b.pathname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function personsForScheduling() {
|
||
return S().persons.map((p) => ({ id: p.id, canMain: Boolean(p.canMain), canCo: Boolean(p.canCo), active: p.active !== false, freeDays: p.freeDays || [] }));
|
||
}
|
||
function personLabel(p) {
|
||
return p ? `${p.firstName} ${p.lastName}`.trim() : 'unbesetzt';
|
||
}
|
||
function publicPerson(p) {
|
||
return { ...p, hasPhoto: store.hasPhoto(p.id) };
|
||
}
|
||
|
||
// ---------- Gesamtzustand ----------
|
||
app.get('/api/state', (req, res) => {
|
||
const st = S();
|
||
const authInfo = { protected: auth.enabled, editor: req.isEditor };
|
||
if (!req.isEditor) {
|
||
// Betrachter: nur, was der Kalender braucht – keine E-Mails, Notizen, Abwesenheiten oder Zugangsdetails
|
||
return res.json({
|
||
auth: authInfo,
|
||
persons: st.persons.map((p) => ({ id: p.id, firstName: p.firstName, lastName: p.lastName, canMain: p.canMain, canCo: p.canCo, active: p.active, photoVersion: p.photoVersion, hasPhoto: store.hasPhoto(p.id) })),
|
||
absences: [],
|
||
holidays: st.holidays,
|
||
assignments: st.assignments,
|
||
requestDates: [...new Set(st.requests.filter((r) => r.status === 'offen').map((r) => r.date))],
|
||
exported: {},
|
||
version: appVersion,
|
||
settings: { meetingTime: st.settings.meetingTime, durationMinutes: st.settings.durationMinutes },
|
||
bundeslaender: BUNDESLAENDER,
|
||
absenceTypes: ABSENCE_TYPES,
|
||
nextcloud: { configured: false },
|
||
});
|
||
}
|
||
res.json({
|
||
auth: authInfo,
|
||
persons: st.persons.map(publicPerson),
|
||
absences: st.absences,
|
||
holidays: st.holidays,
|
||
assignments: st.assignments,
|
||
requests: st.requests,
|
||
requestDates: [...new Set(st.requests.filter((r) => r.status === 'offen').map((r) => r.date))],
|
||
bugs: st.bugs,
|
||
version: appVersion,
|
||
exported: st.exported,
|
||
settings: st.settings,
|
||
bundeslaender: BUNDESLAENDER,
|
||
absenceTypes: ABSENCE_TYPES,
|
||
nextcloud: { configured: nc.configured, calendar: nc.calendar, user: nc.user, baseUrl: nc.baseUrl },
|
||
});
|
||
});
|
||
|
||
// ---------- Personen ----------
|
||
function readPerson(body, existing = {}) {
|
||
const firstName = clean(body.firstName);
|
||
const lastName = clean(body.lastName);
|
||
if (!firstName || !lastName) return { error: 'Vor- und Nachname sind Pflicht.' };
|
||
const email = clean(body.email).toLowerCase();
|
||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'E-Mail-Adresse ist ungültig.' };
|
||
let freeDays = existing.freeDays || [];
|
||
if (body.freeDays !== undefined) {
|
||
if (!Array.isArray(body.freeDays)) return { error: 'freeDays muss eine Liste sein.' };
|
||
freeDays = [...new Set(body.freeDays.map(Number).filter((d) => Number.isInteger(d) && d >= 1 && d <= 5))].sort();
|
||
}
|
||
return {
|
||
person: {
|
||
...existing,
|
||
firstName,
|
||
lastName,
|
||
email,
|
||
canMain: body.canMain !== undefined ? Boolean(body.canMain) : existing.canMain ?? true,
|
||
canCo: body.canCo !== undefined ? Boolean(body.canCo) : existing.canCo ?? true,
|
||
active: body.active !== undefined ? Boolean(body.active) : existing.active ?? true,
|
||
freeDays,
|
||
note: body.note !== undefined ? clean(body.note, 300) : existing.note || '',
|
||
},
|
||
};
|
||
}
|
||
|
||
app.post('/api/persons', (req, res) => {
|
||
const r = readPerson(req.body || {});
|
||
if (r.error) return bad(res, r.error);
|
||
const person = { id: randomUUID(), ...r.person, photoVersion: 0 };
|
||
S().persons.push(person);
|
||
store.save();
|
||
res.status(201).json(publicPerson(person));
|
||
});
|
||
|
||
app.put('/api/persons/:id', (req, res) => {
|
||
const p = S().persons.find((x) => x.id === req.params.id);
|
||
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||
const r = readPerson(req.body || {}, p);
|
||
if (r.error) return bad(res, r.error);
|
||
Object.assign(p, r.person);
|
||
store.save();
|
||
res.json(publicPerson(p));
|
||
});
|
||
|
||
app.delete('/api/persons/:id', (req, res) => {
|
||
const st = S();
|
||
const idx = st.persons.findIndex((x) => x.id === req.params.id);
|
||
if (idx < 0) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||
const id = st.persons[idx].id;
|
||
st.persons.splice(idx, 1);
|
||
st.absences = st.absences.filter((a) => a.personId !== id);
|
||
for (const a of Object.values(st.assignments)) {
|
||
if (a.main === id) a.main = null;
|
||
if (a.co === id) a.co = null;
|
||
}
|
||
store.deletePhoto(id);
|
||
store.save();
|
||
res.status(204).end();
|
||
});
|
||
|
||
app.put('/api/persons/:id/photo', express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '5mb' }), (req, res) => {
|
||
const p = S().persons.find((x) => x.id === req.params.id);
|
||
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||
if (!Buffer.isBuffer(req.body) || req.body.length === 0 || !imageMime(req.body)) return bad(res, 'Kein gültiges Bild übermittelt (erlaubt: JPEG, PNG, WebP).');
|
||
store.savePhoto(p.id, req.body);
|
||
p.photoVersion = (p.photoVersion || 0) + 1;
|
||
store.save();
|
||
res.json(publicPerson(p));
|
||
});
|
||
|
||
app.delete('/api/persons/:id/photo', (req, res) => {
|
||
const p = S().persons.find((x) => x.id === req.params.id);
|
||
if (!p) return res.status(404).json({ error: 'Person nicht gefunden.' });
|
||
store.deletePhoto(p.id);
|
||
p.photoVersion = (p.photoVersion || 0) + 1;
|
||
store.save();
|
||
res.status(204).end();
|
||
});
|
||
|
||
/** Bereits gespeicherte Fotos, die wie Initialen-Platzhalter aussehen. */
|
||
function placeholderPhotoIds() {
|
||
return S().persons.filter((p) => { const buf = store.readPhoto(p.id); return buf && looksLikePlaceholder(buf); }).map((p) => p.id);
|
||
}
|
||
|
||
app.get('/api/persons/placeholder-photos', (req, res) => {
|
||
res.json({ ids: placeholderPhotoIds() });
|
||
});
|
||
|
||
app.post('/api/persons/placeholder-photos/remove', (req, res) => {
|
||
const ids = placeholderPhotoIds();
|
||
for (const id of ids) {
|
||
const p = S().persons.find((x) => x.id === id);
|
||
store.deletePhoto(id);
|
||
if (p) p.photoVersion = (p.photoVersion || 0) + 1;
|
||
}
|
||
store.save();
|
||
res.json({ removed: ids.length, persons: S().persons.map(publicPerson) });
|
||
});
|
||
|
||
app.get('/photos/:id', (req, res) => {
|
||
const id = String(req.params.id).replace(/[^a-f0-9-]/g, '');
|
||
const buf = store.readPhoto(id);
|
||
if (!buf) return res.status(404).end();
|
||
res.set('Cache-Control', 'public, max-age=86400');
|
||
res.type(imageMime(buf) || 'application/octet-stream');
|
||
res.send(buf);
|
||
});
|
||
|
||
// ---------- Abwesenheiten ----------
|
||
app.post('/api/absences', (req, res) => {
|
||
const { personId, from, to, type = 'urlaub', note } = req.body || {};
|
||
if (!S().persons.some((p) => p.id === personId)) return bad(res, 'Person nicht gefunden.');
|
||
if (!isValidDate(from) || !isValidDate(to)) return bad(res, 'Ungültiges Datum.');
|
||
if (to < from) return bad(res, 'Das Ende liegt vor dem Beginn.');
|
||
if (!ABSENCE_TYPES.includes(type)) return bad(res, 'Unbekannte Abwesenheitsart.');
|
||
const absence = { id: randomUUID(), personId, from, to, type, note: clean(note, 200), source: 'manuell' };
|
||
S().absences.push(absence);
|
||
store.save();
|
||
res.status(201).json(absence);
|
||
});
|
||
|
||
app.delete('/api/absences/:id', (req, res) => {
|
||
const st = S();
|
||
const before = st.absences.length;
|
||
st.absences = st.absences.filter((a) => a.id !== req.params.id);
|
||
if (st.absences.length === before) return res.status(404).json({ error: 'Abwesenheit nicht gefunden.' });
|
||
store.save();
|
||
res.status(204).end();
|
||
});
|
||
|
||
// ---------- Feiertage / betriebsfreie Tage ----------
|
||
function upsertHoliday(h) {
|
||
const st = S();
|
||
st.holidays = st.holidays.filter((x) => x.date !== h.date);
|
||
st.holidays.push(h);
|
||
st.holidays.sort((a, b) => a.date.localeCompare(b.date));
|
||
}
|
||
|
||
app.post('/api/holidays', (req, res) => {
|
||
const { date, name } = req.body || {};
|
||
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||
upsertHoliday({ date, name: clean(name) || 'Kein Meeting', auto: false });
|
||
store.save();
|
||
res.status(201).json(S().holidays);
|
||
});
|
||
|
||
app.post('/api/holidays/generate', (req, res) => {
|
||
const year = Number(req.body?.year);
|
||
if (!Number.isInteger(year) || year < 2000 || year > 2100) return bad(res, 'Ungültiges Jahr.');
|
||
for (const h of germanHolidays(year, S().settings.bundesland)) upsertHoliday(h);
|
||
store.save();
|
||
res.json(S().holidays);
|
||
});
|
||
|
||
app.delete('/api/holidays/:date', (req, res) => {
|
||
const st = S();
|
||
st.holidays = st.holidays.filter((h) => h.date !== req.params.date);
|
||
store.save();
|
||
res.status(204).end();
|
||
});
|
||
|
||
// ---------- Einstellungen ----------
|
||
app.put('/api/settings', (req, res) => {
|
||
const s = S().settings;
|
||
const b = req.body || {};
|
||
if (b.meetingTime !== undefined) {
|
||
if (b.meetingTime !== '' && !/^([01]\d|2[0-3]):[0-5]\d$/.test(b.meetingTime)) return bad(res, 'Uhrzeit im Format HH:MM angeben (oder leer für ganztägig).');
|
||
s.meetingTime = b.meetingTime;
|
||
}
|
||
if (b.durationMinutes !== undefined) {
|
||
const d = Number(b.durationMinutes);
|
||
if (!Number.isInteger(d) || d < 5 || d > 480) return bad(res, 'Dauer zwischen 5 und 480 Minuten.');
|
||
s.durationMinutes = d;
|
||
}
|
||
if (b.bundesland !== undefined) {
|
||
if (!Object.hasOwn(BUNDESLAENDER, b.bundesland)) return bad(res, 'Unbekanntes Bundesland.');
|
||
s.bundesland = b.bundesland;
|
||
}
|
||
if (b.calendarDisplayName !== undefined) s.calendarDisplayName = clean(b.calendarDisplayName) || 'Morning Meeting';
|
||
if (b.inviteAttendees !== undefined) s.inviteAttendees = Boolean(b.inviteAttendees);
|
||
if (b.historyWindowDays !== undefined) {
|
||
const d = Number(b.historyWindowDays);
|
||
if (!Number.isInteger(d) || d < 0 || d > 365) return bad(res, 'Fairness-Fenster zwischen 0 und 365 Tagen.');
|
||
s.historyWindowDays = d;
|
||
}
|
||
if (b.bugPromptTemplate !== undefined) s.bugPromptTemplate = clean(b.bugPromptTemplate, 4000) || DEFAULT_PROMPT_TEMPLATE;
|
||
for (const key of ['targetCalendarHref', 'absenceCalendarHref']) {
|
||
if (b[key] === undefined) continue;
|
||
const href = clean(b[key], 500);
|
||
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||
s[key] = href;
|
||
const nameKey = key.replace('Href', 'Name');
|
||
s[nameKey] = href ? clean(b[nameKey], 120) : '';
|
||
}
|
||
store.save();
|
||
res.json(s);
|
||
});
|
||
|
||
// ---------- Planung ----------
|
||
function parseRange(body) {
|
||
const { from, to } = body || {};
|
||
if (!isValidDate(from) || !isValidDate(to)) return { error: 'Ungültiger Zeitraum.' };
|
||
if (to < from) return { error: 'Das Ende liegt vor dem Beginn.' };
|
||
if (addDays(from, 400) < to) return { error: 'Höchstens 400 Tage auf einmal.' };
|
||
return { from, to };
|
||
}
|
||
|
||
app.post('/api/schedule/generate', (req, res) => {
|
||
const r = parseRange(req.body);
|
||
if (r.error) return bad(res, r.error);
|
||
const overwrite = Boolean(req.body.overwrite);
|
||
const st = S();
|
||
const dates = workingDays(r.from, r.to, blockedDays());
|
||
const existing = {};
|
||
for (const d of dates) {
|
||
const ex = st.assignments[d];
|
||
if (!ex) continue;
|
||
// Ohne „überschreiben": volle Tage behalten, halb besetzte nur auffüllen
|
||
existing[d] = overwrite ? { ...ex } : { ...ex, keep: Boolean(ex.main && ex.co), fill: Boolean(ex.main || ex.co) };
|
||
}
|
||
const generated = generateSchedule({
|
||
dates,
|
||
persons: personsForScheduling(),
|
||
absences: st.absences,
|
||
existing,
|
||
history: st.assignments,
|
||
blocked: blockedDays(),
|
||
historyWindowDays: st.settings.historyWindowDays,
|
||
});
|
||
Object.assign(st.assignments, generated);
|
||
for (const d of Object.keys(st.assignments)) {
|
||
if (d >= r.from && d <= r.to && !dates.includes(d) && !st.assignments[d].off) delete st.assignments[d];
|
||
}
|
||
store.save();
|
||
const unfilled = dates.filter((d) => !st.assignments[d].off && (!st.assignments[d].main || !st.assignments[d].co));
|
||
res.json({ assignments: st.assignments, planned: dates.length, unfilled });
|
||
});
|
||
|
||
app.post('/api/schedule/clear', (req, res) => {
|
||
const r = parseRange(req.body);
|
||
if (r.error) return bad(res, r.error);
|
||
const st = S();
|
||
for (const d of Object.keys(st.assignments)) {
|
||
if (d >= r.from && d <= r.to && !st.assignments[d].locked) delete st.assignments[d];
|
||
}
|
||
store.save();
|
||
res.json({ assignments: st.assignments });
|
||
});
|
||
|
||
app.put('/api/assignments/:date', (req, res) => {
|
||
const date = req.params.date;
|
||
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||
const st = S();
|
||
const b = req.body || {};
|
||
const valid = (id) => id === null || id === undefined || st.persons.some((p) => p.id === id);
|
||
if (!valid(b.main) || !valid(b.co)) return bad(res, 'Person nicht gefunden.');
|
||
if (b.main && b.main === b.co) return bad(res, 'Haupt- und Nebenmoderator müssen verschieden sein.');
|
||
const current = st.assignments[date] || {};
|
||
st.assignments[date] = {
|
||
main: b.main !== undefined ? b.main : current.main ?? null,
|
||
co: b.co !== undefined ? b.co : current.co ?? null,
|
||
locked: b.locked !== undefined ? Boolean(b.locked) : Boolean(current.locked),
|
||
off: b.off !== undefined ? Boolean(b.off) : Boolean(current.off),
|
||
};
|
||
store.save();
|
||
res.json(st.assignments[date]);
|
||
});
|
||
|
||
app.post('/api/assignments/:date/reroll', (req, res) => {
|
||
const date = req.params.date;
|
||
if (!isValidDate(date)) return bad(res, 'Ungültiges Datum.');
|
||
const st = S();
|
||
const history = { ...st.assignments };
|
||
delete history[date];
|
||
const generated = generateSchedule({
|
||
dates: [date],
|
||
persons: personsForScheduling(),
|
||
absences: st.absences,
|
||
history,
|
||
blocked: blockedDays(),
|
||
historyWindowDays: st.settings.historyWindowDays,
|
||
});
|
||
// preview: nur Vorschlag liefern, Speichern übernimmt der Dialog
|
||
if (!req.body?.preview) {
|
||
st.assignments[date] = generated[date];
|
||
store.save();
|
||
}
|
||
res.json(generated[date]);
|
||
});
|
||
|
||
app.get('/api/stats', (req, res) => {
|
||
const r = parseRange(req.query);
|
||
if (r.error) return bad(res, r.error);
|
||
res.json(countAssignments(S().assignments, r.from, r.to));
|
||
});
|
||
|
||
// ---------- Tauschanfragen ----------
|
||
const requestHits = new Map(); // ip → Zeitpunkte der letzten Anfragen (Bremse: 10 pro Stunde)
|
||
app.post('/api/requests', (req, res) => {
|
||
const st = S();
|
||
const ip = clientIp(req);
|
||
const now = Date.now();
|
||
const hits = (requestHits.get(ip) || []).filter((t) => now - t < 3_600_000);
|
||
if (hits.length >= 10) return res.status(429).json({ error: 'Zu viele Anfragen in kurzer Zeit. Bitte später noch einmal.' });
|
||
if (st.requests.filter((r) => r.status === 'offen').length >= 200) return res.status(409).json({ error: 'Zu viele offene Anfragen. Bitte die Planung ansprechen.' });
|
||
const r = validateRequest(req.body, st.persons, st.assignments);
|
||
if (r.error) return bad(res, r.error);
|
||
const duplicate = st.requests.find((x) => x.status === 'offen' && x.personId === r.request.personId && x.date === r.request.date);
|
||
if (duplicate) return res.status(409).json({ error: 'Für diesen Tag liegt von dir schon eine offene Anfrage vor.' });
|
||
const request = { id: randomUUID(), ...r.request, status: 'offen', createdAt: new Date().toISOString(), resolvedAt: null };
|
||
st.requests.push(request);
|
||
hits.push(now);
|
||
requestHits.set(ip, hits);
|
||
store.save();
|
||
res.status(201).json({ id: request.id, status: request.status });
|
||
});
|
||
|
||
app.put('/api/requests/:id', (req, res) => {
|
||
const st = S();
|
||
const r = st.requests.find((x) => x.id === req.params.id);
|
||
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||
const status = String(req.body?.status || '');
|
||
if (!REQUEST_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
|
||
r.status = status;
|
||
r.resolvedAt = status === 'offen' ? null : new Date().toISOString();
|
||
store.save();
|
||
res.json(r);
|
||
});
|
||
|
||
app.post('/api/requests/:id/execute', (req, res) => {
|
||
const st = S();
|
||
const r = st.requests.find((x) => x.id === req.params.id);
|
||
if (!r) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||
if (r.status !== 'offen') return bad(res, 'Diese Anfrage ist schon abgeschlossen.');
|
||
try {
|
||
const { changes } = applySwap(st.assignments, r);
|
||
r.status = 'erledigt';
|
||
r.resolvedAt = new Date().toISOString();
|
||
store.save();
|
||
res.json({ request: r, changes, assignments: st.assignments });
|
||
} catch (err) {
|
||
bad(res, err.message);
|
||
}
|
||
});
|
||
|
||
app.delete('/api/requests/:id', (req, res) => {
|
||
const st = S();
|
||
const before = st.requests.length;
|
||
st.requests = st.requests.filter((x) => x.id !== req.params.id);
|
||
if (st.requests.length === before) return res.status(404).json({ error: 'Anfrage nicht gefunden.' });
|
||
store.save();
|
||
res.status(204).end();
|
||
});
|
||
|
||
// ---------- Fehlermeldungen ----------
|
||
const bugMeta = (b) => b;
|
||
app.post('/api/bugs', (req, res) => {
|
||
const st = S();
|
||
if (st.bugs.filter((b) => b.status === 'offen').length >= 500) return res.status(409).json({ error: 'Zu viele offene Meldungen. Bitte die Planung direkt ansprechen.' });
|
||
const b = req.body || {};
|
||
const text = clean(b.text, 2000);
|
||
if (!text) return bad(res, 'Bitte beschreibe kurz, was falsch läuft.');
|
||
let screenshot = null;
|
||
try { screenshot = parseScreenshot(b.screenshot); } catch (err) { return bad(res, err.message); }
|
||
const bug = {
|
||
id: randomUUID(),
|
||
createdAt: new Date().toISOString(),
|
||
text,
|
||
reporter: clean(b.reporter, 80),
|
||
page: clean(b.page, 120),
|
||
url: clean(b.url, 500),
|
||
viewport: clean(b.viewport, 40),
|
||
userAgent: clean(b.userAgent, 300),
|
||
version: appVersion,
|
||
status: 'offen',
|
||
resolvedAt: null,
|
||
hasScreenshot: Boolean(screenshot),
|
||
};
|
||
if (screenshot) {
|
||
store.saveBug(bug.id, screenshot);
|
||
// Ohne Content-Length (gestückelter Body) die echte Größe nachtragen
|
||
const hits = bugHits.get(clientIp(req)) || [];
|
||
const last = hits[hits.length - 1];
|
||
if (last && last.bytes === 0) last.bytes = screenshot.length;
|
||
}
|
||
st.bugs.push(bug);
|
||
store.save();
|
||
res.status(201).json({ id: bug.id });
|
||
});
|
||
|
||
const findBug = (req, res) => {
|
||
const bug = S().bugs.find((x) => x.id === req.params.id);
|
||
if (!bug) res.status(404).json({ error: 'Meldung nicht gefunden.' });
|
||
return bug;
|
||
};
|
||
|
||
app.get('/api/bugs/:id/prompt', (req, res) => {
|
||
const bug = findBug(req, res);
|
||
if (!bug) return;
|
||
res.type('text/plain; charset=utf-8').send(buildPrompt(S().settings.bugPromptTemplate, bug, { version: bug.version || appVersion }));
|
||
});
|
||
|
||
app.put('/api/bugs/:id', (req, res) => {
|
||
const bug = findBug(req, res);
|
||
if (!bug) return;
|
||
const status = String(req.body?.status || '');
|
||
if (!BUG_STATUS.includes(status)) return bad(res, 'Unbekannter Status.');
|
||
bug.status = status;
|
||
bug.resolvedAt = status === 'offen' ? null : new Date().toISOString();
|
||
store.save();
|
||
res.json(bugMeta(bug));
|
||
});
|
||
|
||
app.delete('/api/bugs/:id', (req, res) => {
|
||
const st = S();
|
||
const bug = findBug(req, res);
|
||
if (!bug) return;
|
||
st.bugs = st.bugs.filter((x) => x.id !== bug.id);
|
||
store.deleteBug(bug.id);
|
||
store.save();
|
||
res.status(204).end();
|
||
});
|
||
|
||
/** Screenshots nur für Bearbeiter – sie zeigen Namen und Planung. */
|
||
app.get('/bugs/:id.png', (req, res) => {
|
||
if (!req.isEditor) return res.status(403).type('text/plain').send('Anmeldung erforderlich.');
|
||
const id = String(req.params.id);
|
||
if (!/^[a-f0-9-]{36}$/.test(id)) return res.status(404).end();
|
||
const buf = store.readBug(id);
|
||
if (!buf) return res.status(404).end();
|
||
res.set('Cache-Control', 'private, max-age=3600');
|
||
res.type(imageMime(buf) || 'image/png');
|
||
res.send(buf);
|
||
});
|
||
|
||
// ---------- Export ----------
|
||
function eventsForRange(from, to) {
|
||
const st = S();
|
||
const byId = Object.fromEntries(st.persons.map((p) => [p.id, p]));
|
||
const blocked = blockedDays();
|
||
const organizer = st.settings.inviteAttendees && /@/.test(nc.user) ? { name: 'Morning Meeting (Rotatory)', email: nc.user } : null;
|
||
const events = [];
|
||
for (const [date, a] of Object.entries(st.assignments).sort()) {
|
||
if (date < from || date > to || a.off || blocked.has(date) || (!a.main && !a.co)) continue;
|
||
const main = byId[a.main];
|
||
const co = byId[a.co];
|
||
const attendees = organizer
|
||
? [main, co].filter((p) => p && p.email).map((p) => ({ name: personLabel(p), email: p.email }))
|
||
: [];
|
||
events.push({
|
||
date,
|
||
uid: eventUid(date, nc.baseUrl ? new URL(nc.baseUrl).hostname : 'rotatory.local'),
|
||
summary: `Morning Meeting: ${personLabel(main)} / ${personLabel(co)}`,
|
||
description: `Hauptmoderation: ${personLabel(main)}\nNebenmoderation: ${personLabel(co)}\n\nGeplant mit Rotatory.`,
|
||
time: st.settings.meetingTime,
|
||
durationMinutes: st.settings.durationMinutes,
|
||
attendees,
|
||
organizer,
|
||
sequence: (st.exported[date]?.sequence || 0),
|
||
});
|
||
}
|
||
return events;
|
||
}
|
||
|
||
app.get('/api/export.ics', (req, res) => {
|
||
const r = parseRange(req.query);
|
||
if (r.error) return bad(res, r.error);
|
||
const ics = buildCalendar(eventsForRange(r.from, r.to).map(buildEventLines), { name: S().settings.calendarDisplayName, method: 'PUBLISH' });
|
||
res.set('Content-Type', 'text/calendar; charset=utf-8');
|
||
res.set('Content-Disposition', `attachment; filename="morning-meeting-${r.from}-${r.to}.ics"`);
|
||
res.send(ics);
|
||
});
|
||
|
||
// ---------- Nextcloud ----------
|
||
function nextcloudClient() {
|
||
if (!nc.configured) throw new Error('Nextcloud ist nicht konfiguriert: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD setzen.');
|
||
return createNextcloudClient({ ...nc, displayName: S().settings.calendarDisplayName });
|
||
}
|
||
const ncError = (res, err) => res.status(502).json({ error: err.message });
|
||
|
||
app.post('/api/nextcloud/test', async (req, res) => {
|
||
try {
|
||
const r = await nextcloudClient().test();
|
||
res.json({ ...r, target: S().settings.targetCalendarHref, absenceSource: S().settings.absenceCalendarHref });
|
||
} catch (err) { ncError(res, err); }
|
||
});
|
||
|
||
app.get('/api/nextcloud/calendars', async (req, res) => {
|
||
try {
|
||
res.json({ calendars: await nextcloudClient().listCalendars(), defaultId: nc.calendar });
|
||
} catch (err) { ncError(res, err); }
|
||
});
|
||
|
||
app.post('/api/nextcloud/sync', async (req, res) => {
|
||
const r = parseRange(req.body);
|
||
if (r.error) return bad(res, r.error);
|
||
const removeForeign = Boolean(req.body.removeForeign);
|
||
const st = S();
|
||
if (req.body.targetCalendarHref !== undefined) {
|
||
const href = clean(req.body.targetCalendarHref, 500);
|
||
if (href && !isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||
st.settings.targetCalendarHref = href;
|
||
st.settings.targetCalendarName = href ? clean(req.body.targetCalendarName, 120) : '';
|
||
}
|
||
try {
|
||
const client = nextcloudClient();
|
||
const { href, created } = await client.ensureCalendar(st.settings.targetCalendarHref || null);
|
||
// Zielwechsel: Termine im Zeitraum, die noch in einem anderen Kalender liegen, dort entfernen (sie werden gleich neu geschrieben)
|
||
const moved = [];
|
||
for (const [date, info] of Object.entries(st.exported)) {
|
||
if (date >= r.from && date <= r.to && info.calendarHref && info.calendarHref !== href) {
|
||
try { await client.deleteEvent(info.calendarHref, info.uid); moved.push(date); delete st.exported[date]; } catch { /* alter Kalender ggf. weg */ }
|
||
}
|
||
}
|
||
const events = eventsForRange(r.from, r.to);
|
||
const written = [];
|
||
const errors = [];
|
||
for (const ev of events) {
|
||
const sequence = (st.exported[ev.date]?.sequence || 0) + 1;
|
||
try {
|
||
await client.putEvent(href, ev.uid, buildCalendar([buildEventLines({ ...ev, sequence })]));
|
||
st.exported[ev.date] = { uid: ev.uid, sequence, at: new Date().toISOString(), calendarHref: href };
|
||
written.push(ev.date);
|
||
} catch (err) {
|
||
errors.push(`${ev.date}: ${err.message}`);
|
||
}
|
||
}
|
||
const removed = [];
|
||
const stillThere = new Set(events.map((e) => e.date));
|
||
for (const [date, info] of Object.entries(st.exported)) {
|
||
if (date < r.from || date > r.to || stillThere.has(date)) continue;
|
||
try {
|
||
await client.deleteEvent(href, info.uid);
|
||
delete st.exported[date];
|
||
removed.push(date);
|
||
} catch (err) {
|
||
errors.push(`${date}: ${err.message}`);
|
||
}
|
||
}
|
||
// Fremde Termine im Zeitraum (z. B. aus einer früheren Planung mit einem anderen Werkzeug)
|
||
let foreign = 0;
|
||
let foreignRemoved = 0;
|
||
let foreignRecurring = 0;
|
||
try {
|
||
const existing = await client.queryEvents(href, r.from, r.to);
|
||
for (const item of existing) {
|
||
const evs = parseEvents(item.ics);
|
||
if (evs.length === 0 || evs.some((e) => e.uid.startsWith('rotatory-'))) continue;
|
||
if (!evs.some((e) => e.to >= r.from && e.from <= r.to)) continue; // Server filtert grob, wir genau
|
||
if (evs.some((e) => e.rrule)) { foreignRecurring += 1; continue; } // ganze Serien nie löschen
|
||
foreign += 1;
|
||
if (removeForeign) {
|
||
try { await client.deleteEvent(href, item.href); foreignRemoved += 1; } catch (err) { errors.push(`Fremdtermin: ${err.message}`); }
|
||
}
|
||
}
|
||
} catch (err) {
|
||
errors.push(`Fremde Termine konnten nicht geprüft werden: ${err.message}`);
|
||
}
|
||
store.save();
|
||
res.json({ created, written: written.length, removed: removed.length, moved: moved.length, foreign, foreignRemoved, foreignRecurring, errors, calendarUrl: href });
|
||
} catch (err) {
|
||
store.save();
|
||
ncError(res, err);
|
||
}
|
||
});
|
||
|
||
/** Abwesenheiten aus einem Nextcloud-Kalender lesen ("Vorname Nachname (Urlaub)"). */
|
||
app.post('/api/nextcloud/absences/import', async (req, res) => {
|
||
const r = parseRange(req.body);
|
||
if (r.error) return bad(res, r.error);
|
||
const st = S();
|
||
const href = clean(req.body.calendarHref, 500) || st.settings.absenceCalendarHref;
|
||
if (!href) return bad(res, 'Kein Quellkalender gewählt.');
|
||
if (!isOurNextcloudUrl(href)) return bad(res, 'Kalender-Adresse gehört nicht zur konfigurierten Nextcloud.');
|
||
try {
|
||
const client = nextcloudClient();
|
||
const items = await client.queryEvents(href, r.from, r.to, { expand: true });
|
||
const found = [];
|
||
const unmatched = new Map();
|
||
let cancelled = 0;
|
||
for (const item of items) {
|
||
for (const ev of parseEvents(item.ics)) {
|
||
if (ev.status === 'CANCELLED') { cancelled += 1; continue; }
|
||
if (ev.to < r.from || ev.from > r.to) continue;
|
||
const { name, kind } = parseAbsenceSummary(ev.summary);
|
||
const person = matchPerson(name, st.persons);
|
||
if (!person) { unmatched.set(name, (unmatched.get(name) || 0) + 1); continue; }
|
||
found.push({ personId: person.id, from: ev.from, to: ev.to, type: kind });
|
||
}
|
||
}
|
||
const merged = mergeAbsences(found).map((a) => ({ id: randomUUID(), ...a, note: '', source: 'nextcloud' }));
|
||
// Früher importierte Abwesenheiten, die den Zeitraum berühren, ersetzen
|
||
const before = st.absences.length;
|
||
st.absences = st.absences.filter((a) => !(a.source === 'nextcloud' && a.from <= r.to && a.to >= r.from));
|
||
const replaced = before - st.absences.length;
|
||
st.absences.push(...merged);
|
||
if (req.body.calendarHref) { st.settings.absenceCalendarHref = href; st.settings.absenceCalendarName = clean(req.body.calendarName, 120); }
|
||
store.save();
|
||
res.json({ imported: merged.length, replaced, cancelled, events: items.length, unmatched: [...unmatched.entries()].map(([name, count]) => ({ name, count })), absences: st.absences });
|
||
} catch (err) { ncError(res, err); }
|
||
});
|
||
|
||
/** Kontakte (Nextcloud-Konten) zur Auswahl anbieten. */
|
||
app.get('/api/nextcloud/contacts', async (req, res) => {
|
||
try {
|
||
const client = nextcloudClient();
|
||
const books = await client.listAddressBooks();
|
||
if (books.length === 0) return res.json({ addressbooks: [], contacts: [] });
|
||
const wanted = clean(req.query.addressbook, 500);
|
||
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
|
||
const cards = await client.queryContacts(book.href);
|
||
const st = S();
|
||
const contacts = [];
|
||
for (const raw of cards) {
|
||
for (const c of parseVCards(raw)) {
|
||
if (!c.firstName && !c.lastName) continue;
|
||
const existing = st.persons.find((p) => (c.email && p.email === c.email) || (p.firstName.toLowerCase() === c.firstName.toLowerCase() && p.lastName.toLowerCase() === c.lastName.toLowerCase()));
|
||
contacts.push({ uid: c.uid, firstName: c.firstName, lastName: c.lastName, email: c.email, hasPhoto: Boolean(c.photo) || book.system, existingId: existing?.id || null });
|
||
}
|
||
}
|
||
contacts.sort((a, b) => `${a.lastName} ${a.firstName}`.localeCompare(`${b.lastName} ${b.firstName}`, 'de'));
|
||
res.json({ addressbooks: books, addressbook: book.href, contacts });
|
||
} catch (err) { ncError(res, err); }
|
||
});
|
||
|
||
/** Ausgewählte Kontakte als Personen anlegen, Fotos mitnehmen. */
|
||
app.post('/api/nextcloud/contacts/import', async (req, res) => {
|
||
const uids = Array.isArray(req.body?.uids) ? req.body.uids.map((u) => clean(u, 200)).filter(Boolean) : [];
|
||
if (uids.length === 0) return bad(res, 'Keine Kontakte ausgewählt.');
|
||
try {
|
||
const client = nextcloudClient();
|
||
const books = await client.listAddressBooks();
|
||
const wanted = clean(req.body.addressbook, 500);
|
||
const book = books.find((b) => b.href === wanted) || books.find((b) => b.system) || books[0];
|
||
if (!book) return bad(res, 'Kein Adressbuch verfügbar.');
|
||
const cards = (await client.queryContacts(book.href)).flatMap(parseVCards);
|
||
const st = S();
|
||
const created = [];
|
||
const photos = [];
|
||
const skipped = [];
|
||
let placeholders = 0;
|
||
for (const uid of uids) {
|
||
const c = cards.find((x) => x.uid === uid);
|
||
if (!c || (!c.firstName && !c.lastName)) { skipped.push(uid); continue; }
|
||
const r = readPerson({ firstName: c.firstName || c.lastName, lastName: c.lastName || c.firstName, email: c.email, note: '' });
|
||
if (r.error) { skipped.push(uid); continue; }
|
||
const person = { id: randomUUID(), ...r.person, photoVersion: 0, nextcloudUid: uid };
|
||
st.persons.push(person);
|
||
created.push(person.id);
|
||
let img = null;
|
||
if (book.system) img = await client.fetchAvatar(uid).catch(() => null);
|
||
if (!img && c.photo?.kind === 'inline') { const buffer = Buffer.from(c.photo.base64, 'base64'); if (imageMime(buffer)) img = { buffer }; }
|
||
if (!img && c.photo?.kind === 'url') img = await client.fetchBinary(c.photo.url).catch(() => null);
|
||
// Generierte Initialen-Avatare zählen nicht als Foto, auch wenn Nextcloud sie als „eigen" meldet
|
||
if (img && imageMime(img.buffer) && !looksLikePlaceholder(img.buffer)) { store.savePhoto(person.id, img.buffer); person.photoVersion = 1; photos.push(person.id); }
|
||
else if (img) placeholders += 1;
|
||
}
|
||
store.save();
|
||
res.json({ created: created.length, withPhoto: photos.length, placeholders, skipped: skipped.length, persons: st.persons.map(publicPerson) });
|
||
} catch (err) { ncError(res, err); }
|
||
});
|
||
|
||
app.use('/api', (req, res) => res.status(404).json({ error: 'Unbekannter Endpunkt.' }));
|
||
app.use((err, req, res, next) => {
|
||
if (res.headersSent) return next(err);
|
||
const status = err.status || err.statusCode || 500;
|
||
res.status(status).json({ error: status === 500 ? 'Interner Fehler.' : err.message });
|
||
if (status === 500) console.error(err);
|
||
});
|
||
|
||
export function start(port = Number(process.env.PORT) || 3000) {
|
||
return app.listen(port, () => {
|
||
console.log(`Rotatory läuft auf Port ${port}, Daten in ${dataDir}, Nextcloud ${nc.configured ? 'konfiguriert' : 'nicht konfiguriert'}`);
|
||
});
|
||
}
|
||
|
||
// Nur beim direkten Start lauschen; Tests importieren die App und starten sie auf einem freien Port
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) start();
|