Bearbeitung hinter ein Passwort legen, Ansehen für das Team freigeben

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>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 12:36:23 +00:00
1 parent 9ed59bcf56
commit 33a8adef0e
8 files changed
+288 -13

No files matched your search

+57
View File
@@ -5,6 +5,7 @@ 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';
@@ -46,6 +47,45 @@ if (appPassword) {
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 });
@@ -85,7 +125,24 @@ function publicPerson(p) {
// ---------- 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,