Mit EDIT_PASSWORD sehen Besucher nur den Kalender: keine E-Mails, Notizen, Abwesenheiten oder Nextcloud-Details, keine schreibenden Aktionen. Anmelden in der Kopfzeile setzt ein signiertes Cookie für 30 Tage; nach fünf Fehlversuchen wartet der Server eine Minute. Ohne EDIT_PASSWORD bleibt alles offen wie bisher. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
694 lines
30 KiB
JavaScript
694 lines
30 KiB
JavaScript
import express from 'express';
|
||
import path from 'node:path';
|
||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||
import { fileURLToPath } 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 { 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).');
|
||
}
|
||
|
||
const app = express();
|
||
app.disable('x-powered-by');
|
||
app.set('etag', false);
|
||
|
||
// 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.');
|
||
});
|
||
}
|
||
|
||
app.use(express.json({ limit: '1mb' }));
|
||
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 clientIp = (req) => (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '?';
|
||
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();
|
||
});
|
||
const needsEditor = (req) => {
|
||
if (!req.path.startsWith('/api/')) return false;
|
||
if (req.path.startsWith('/api/auth')) return false;
|
||
if (req.method !== 'GET' && req.method !== 'HEAD') return true;
|
||
return req.path.startsWith('/api/nextcloud/') || req.path.startsWith('/api/persons/placeholder');
|
||
};
|
||
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,
|
||
exported: {},
|
||
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,
|
||
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;
|
||
}
|
||
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));
|
||
});
|
||
|
||
// ---------- 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);
|
||
});
|
||
|
||
const port = Number(process.env.PORT) || 3000;
|
||
app.listen(port, () => {
|
||
console.log(`Rotatory läuft auf Port ${port}, Daten in ${dataDir}, Nextcloud ${nc.configured ? 'konfiguriert' : 'nicht konfiguriert'}`);
|
||
});
|