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 { 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'))); 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(); res.json({ 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'}`); });