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

+4 -1
View File
@@ -6,5 +6,8 @@ NEXTCLOUD_APP_PASSWORD= # optional App-Passwort aus Nextcloud → Einstellung
NEXTCLOUD_CALENDAR=morning-meeting NEXTCLOUD_CALENDAR=morning-meeting
# Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat # Nur setzen, wenn die Nextcloud ein selbstsigniertes Zertifikat hat
NEXTCLOUD_INSECURE_TLS=false NEXTCLOUD_INSECURE_TLS=false
# Optionaler Zugriffsschutz der App (HTTP Basic Auth). Leer = kein Passwort. # Bearbeitungspasswort: Betrachter sehen nur den Kalender, Auslosen und Verwalten erfordern die Anmeldung.
# Leer = jeder darf alles bearbeiten.
EDIT_PASSWORD=""
# Optionaler Zugriffsschutz der ganzen App (HTTP Basic Auth, auch fürs Ansehen). Leer = kein Passwort.
APP_PASSWORD="" APP_PASSWORD=""
+11 -1
View File
@@ -63,11 +63,21 @@ Alle Variablen sind optional, siehe `.env.example`:
| `NEXTCLOUD_APP_PASSWORD` | App-Passwort (Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen) | | `NEXTCLOUD_APP_PASSWORD` | App-Passwort (Nextcloud → Einstellungen → Sicherheit → Geräte & Sitzungen) |
| `NEXTCLOUD_CALENDAR` | ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird (Vorgabe `morning-meeting`) | | `NEXTCLOUD_CALENDAR` | ID des eigenen Kalenders, falls kein vorhandener Zielkalender gewählt wird (Vorgabe `morning-meeting`) |
| `NEXTCLOUD_INSECURE_TLS` | `true`, wenn die Nextcloud ein selbstsigniertes Zertifikat nutzt | | `NEXTCLOUD_INSECURE_TLS` | `true`, wenn die Nextcloud ein selbstsigniertes Zertifikat nutzt |
| `APP_PASSWORD` | Wenn gesetzt, schützt HTTP Basic Auth die ganze App (Benutzername beliebig) | | `EDIT_PASSWORD` | Bearbeitungspasswort. Wenn gesetzt, sehen alle nur den Kalender; Auslosen, Personen, Abwesenheiten, Einstellungen und Nextcloud erfordern die Anmeldung über „Anmelden" in der Kopfzeile |
| `APP_PASSWORD` | Wenn gesetzt, schützt HTTP Basic Auth die ganze App inklusive Ansehen (Benutzername beliebig) |
Ohne Nextcloud-Variablen läuft die App vollständig, nur der Button „Go – schreiben" ist Ohne Nextcloud-Variablen läuft die App vollständig, nur der Button „Go – schreiben" ist
deaktiviert; der ICS-Download bleibt verfügbar. deaktiviert; der ICS-Download bleibt verfügbar.
## Teilen im Team
Die Adresse der App kann an alle weitergegeben werden. Mit gesetztem `EDIT_PASSWORD` sehen
Besucher den Monatskalender mit Fotos und die Einsatzstatistik, aber keine E-Mail-Adressen,
Notizen oder Abwesenheiten, und sie können nichts ändern. Wer bearbeiten will, klickt oben rechts
auf „Anmelden". Die Anmeldung gilt 30 Tage im jeweiligen Browser; nach fünf falschen Versuchen
wartet der Server eine Minute. Ohne `EDIT_PASSWORD` darf jeder alles, wie bei einem Werkzeug
für eine einzelne Person.
## Nextcloud ## Nextcloud
1. Umgebungsvariablen setzen und App neu starten. 1. Umgebungsvariablen setzen und App neu starten.
+62 -11
View File
@@ -43,10 +43,51 @@ async function api(path, { method = 'GET', body, raw } = {}) {
const res = await fetch(path, opts); const res = await fetch(path, opts);
if (res.status === 204) return null; if (res.status === 204) return null;
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); if (!res.ok) {
if (data.authRequired) { openLogin(); throw new Error('Zum Bearbeiten bitte anmelden.'); }
throw new Error(data.error || `HTTP ${res.status}`);
}
return data; return data;
} }
// ---------- Anmeldung zum Bearbeiten ----------
const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen'];
function isEditor() { return !state.data || state.data.auth?.editor !== false; }
function applyAuthUI() {
const a = state.data?.auth || { protected: false, editor: true };
document.body.classList.toggle('viewer', !a.editor);
const btn = $('#auth-button');
btn.hidden = !a.protected;
btn.textContent = a.editor ? 'Abmelden' : 'Anmelden';
for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view);
$('#open-plan').hidden = !a.editor;
$('#open-nextcloud').hidden = !a.editor;
if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender';
}
function openLogin() {
const dlg = $('#login-dialog');
if (dlg.open) return;
$('#login-result').hidden = true;
$('#login-password').value = '';
dlg.showModal();
}
$('#auth-button').addEventListener('click', async () => {
if (isEditor()) {
try { await api('api/auth/logout', { method: 'POST' }); await load(); toast('Abgemeldet. Du siehst jetzt die Ansicht für das Team.'); } catch (err) { toast(err.message, true); }
} else openLogin();
});
$('#login-close').addEventListener('click', () => $('#login-dialog').close());
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const out = $('#login-result');
try {
await api('api/auth/login', { method: 'POST', body: { password: $('#login-password').value } });
$('#login-dialog').close();
await load();
toast('Angemeldet. Bearbeiten ist freigeschaltet.');
} catch (err) { out.textContent = err.message; out.hidden = false; }
});
async function load() { async function load() {
state.data = await api('api/state'); state.data = await api('api/state');
renderAll(); renderAll();
@@ -100,6 +141,7 @@ function avatarEl(p, cls = '') {
function showView(name) { function showView(name) {
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen']; const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
if (!known.includes(name)) name = 'kalender'; if (!known.includes(name)) name = 'kalender';
if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender';
if (name === 'abwesenheiten' && state.data) prepareAbsenceImport(); if (name === 'abwesenheiten' && state.data) prepareAbsenceImport();
for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`; for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`;
for (const a of $$('.topbar nav a')) { for (const a of $$('.topbar nav a')) {
@@ -128,9 +170,10 @@ function renderCalendar() {
if (cur.getDay() === 0 || cur.getDay() === 6) continue; if (cur.getDay() === 0 || cur.getDay() === 6) continue;
const date = iso(cur); const date = iso(cur);
const outside = cur.getMonth() !== d.getMonth(); const outside = cur.getMonth() !== d.getMonth();
const cell = document.createElement(outside ? 'div' : 'button'); const editable = !outside && isEditor();
const cell = document.createElement(editable ? 'button' : 'div');
cell.className = 'day'; cell.className = 'day';
if (!outside) { cell.type = 'button'; cell.dataset.date = date; } if (editable) { cell.type = 'button'; cell.dataset.date = date; }
if (outside) cell.classList.add('outside'); if (outside) cell.classList.add('outside');
if (date === today) cell.classList.add('today'); if (date === today) cell.classList.add('today');
if (date < today) cell.classList.add('past'); if (date < today) cell.classList.add('past');
@@ -584,7 +627,7 @@ function imageFromClipboard(data) {
} }
// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular // Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular
document.addEventListener('paste', (e) => { document.addEventListener('paste', (e) => {
if ($('#view-personen').hidden || !state.data) return; if ($('#view-personen').hidden || !state.data || !isEditor()) return;
const file = imageFromClipboard(e.clipboardData); const file = imageFromClipboard(e.clipboardData);
if (!file) return; if (!file) return;
e.preventDefault(); e.preventDefault();
@@ -920,13 +963,16 @@ function renderSettings() {
list.appendChild(li); list.appendChild(li);
} }
const protectedHint = state.data.auth?.protected
? '<p class="status-ok">Bearbeitung ist durch das Passwort aus <code>EDIT_PASSWORD</code> geschützt. Das Team sieht nur den Kalender.</p>'
: '<p class="status-warn">Kein Bearbeitungspasswort gesetzt: Jeder, der die Seite öffnet, kann auch bearbeiten. Zum Schützen <code>EDIT_PASSWORD</code> als Umgebungsvariable setzen.</p>';
const nc = state.data.nextcloud; const nc = state.data.nextcloud;
const box = $('#nextcloud-status'); const box = $('#nextcloud-status');
box.innerHTML = nc.configured box.innerHTML = protectedHint + (nc.configured
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>` ? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>` + `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>` + `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`; : `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`);
} }
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
@@ -962,12 +1008,17 @@ $('#holiday-add').addEventListener('submit', async (e) => {
// ---------- Start ---------- // ---------- Start ----------
function renderAll() { function renderAll() {
applyAuthUI();
renderCalendar(); renderCalendar();
renderPersons(); if (isEditor()) {
renderPhotoField(); renderPersons();
renderAbsences(); renderPhotoField();
renderSettings(); renderAbsences();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport(); renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
} else {
showView(location.hash.slice(1));
}
} }
showView(location.hash.slice(1)); showView(location.hash.slice(1));
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true)); load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));
+16
View File
@@ -18,6 +18,7 @@
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a> <a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a> <a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
</nav> </nav>
<button type="button" id="auth-button" class="topbar-action" hidden>Anmelden</button>
</header> </header>
<main id="main"> <main id="main">
@@ -255,6 +256,21 @@
</form> </form>
</dialog> </dialog>
<!-- Dialog: Anmelden zum Bearbeiten -->
<dialog id="login-dialog">
<form method="dialog" class="dialog-form" id="login-form">
<h2>Zum Bearbeiten anmelden</h2>
<p class="hint">Ansehen kann jeder. Auslosen, Personen, Abwesenheiten und Nextcloud erfordern das Bearbeitungspasswort.</p>
<label>Passwort <input type="password" name="password" id="login-password" autocomplete="current-password" required></label>
<p id="login-result" class="result error" hidden></p>
<div class="form-actions">
<span class="spacer"></span>
<button type="button" class="ghost" id="login-close">Abbrechen</button>
<button type="submit" class="primary">Anmelden</button>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div> <div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="app.js"></script> <script type="module" src="app.js"></script>
</body> </body>
+7
View File
@@ -59,12 +59,19 @@ svg.icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-w
.topbar nav a:hover { color: var(--white); } .topbar nav a:hover { color: var(--white); }
.topbar nav a[aria-current="page"] { color: var(--white); border-bottom-color: var(--white); } .topbar nav a[aria-current="page"] { color: var(--white); border-bottom-color: var(--white); }
.topbar nav a:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; } .topbar nav a:focus-visible { outline: 2px solid var(--white); outline-offset: -4px; }
.topbar-action { margin-left: auto; background: transparent; color: var(--white); border-color: rgba(255, 255, 255, .4); min-height: 34px; padding: 0 12px; font-size: 13px; }
.topbar-action:hover { background: rgba(255, 255, 255, .1); }
.topbar-action:focus-visible { outline: 2px solid var(--white); outline-offset: 2px; }
/* Betrachtermodus: Kalender ansehen, nichts bearbeiten */
.viewer .day { cursor: default; }
.viewer .day:hover:not(.outside) { background: var(--white); }
@media (max-width: 560px) { @media (max-width: 560px) {
.topbar { padding: 0 16px; gap: 0 16px; } .topbar { padding: 0 16px; gap: 0 16px; }
.topbar h1 { padding: 12px 0; } .topbar h1 { padding: 12px 0; }
.topbar nav { flex-wrap: nowrap; overflow-x: auto; width: 100%; margin: 0 -16px; padding: 0 8px; scrollbar-width: none; } .topbar nav { flex-wrap: nowrap; overflow-x: auto; width: 100%; margin: 0 -16px; padding: 0 8px; scrollbar-width: none; }
.topbar nav::-webkit-scrollbar { display: none; } .topbar nav::-webkit-scrollbar { display: none; }
.topbar nav a { min-height: 44px; padding: 0 10px; font-size: 13px; white-space: nowrap; } .topbar nav a { min-height: 44px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
.topbar-action { margin-left: auto; margin-block: 8px; }
} }
button:focus-visible, input:focus-visible, select:focus-visible, .button:focus-visible, .day:focus-visible, .avatar[tabindex]:focus-visible { button:focus-visible, input:focus-visible, select:focus-visible, .button:focus-visible, .day:focus-visible, .avatar[tabindex]:focus-visible {
outline: 2px solid var(--tradeo-blue); outline-offset: 2px; outline: 2px solid var(--tradeo-blue); outline-offset: 2px;
+80
View File
@@ -0,0 +1,80 @@
// Bearbeitungsschutz: Betrachter sehen den Kalender, Bearbeiten erfordert das Passwort aus EDIT_PASSWORD.
// Nach erfolgreicher Anmeldung bekommt der Browser ein signiertes Cookie (HMAC, 30 Tage).
import fs from 'node:fs';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
export const COOKIE_NAME = 'rotatory_edit';
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
return ba.length === bb.length && timingSafeEqual(ba, bb);
}
export function createAuth({ password = '', secretFile = null, secret = null, now = () => Date.now() } = {}) {
const pw = String(password || '').trim();
let key = secret;
if (!key && secretFile) {
if (fs.existsSync(secretFile)) key = fs.readFileSync(secretFile, 'utf8').trim();
if (!key) { key = randomBytes(32).toString('hex'); fs.writeFileSync(secretFile, key, { mode: 0o600 }); }
}
if (!key) key = randomBytes(32).toString('hex');
const sign = (payload) => createHmac('sha256', key).update(payload).digest('base64url');
// Bremse gegen Raten: nach 5 Fehlversuchen je Adresse 60 Sekunden Sperre
const failures = new Map();
return {
enabled: pw.length > 0,
ttlMs: TTL_MS,
checkPassword(given) {
return this.enabled && safeEqual(given ?? '', pw);
},
issue() {
const exp = String(now() + TTL_MS);
return `${exp}.${sign(exp)}`;
},
verify(token) {
if (!token || typeof token !== 'string') return false;
const dot = token.indexOf('.');
if (dot < 0) return false;
const exp = token.slice(0, dot);
const sig = token.slice(dot + 1);
if (!/^\d+$/.test(exp) || Number(exp) < now()) return false;
return safeEqual(sig, sign(exp));
},
/** Liefert die Restsperre in Sekunden (0 = Versuch erlaubt). */
blockedFor(ip) {
const f = failures.get(ip);
if (!f) return 0;
if (f.until && f.until > now()) return Math.ceil((f.until - now()) / 1000);
return 0;
},
noteFailure(ip) {
const f = failures.get(ip) || { count: 0, until: 0 };
f.count += 1;
if (f.count >= 5) { f.until = now() + 60_000; f.count = 0; }
failures.set(ip, f);
},
noteSuccess(ip) { failures.delete(ip); },
};
}
export function readCookie(header, name = COOKIE_NAME) {
for (const part of String(header || '').split(';')) {
const [k, ...rest] = part.trim().split('=');
if (k === name) return decodeURIComponent(rest.join('='));
}
return null;
}
export function cookieHeader(token, { secure = false, maxAgeMs = TTL_MS } = {}) {
const parts = [`${COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`];
if (secure) parts.push('Secure');
return parts.join('; ');
}
+57
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { loadEnvFile } from './env.js'; import { loadEnvFile } from './env.js';
import { createStore, imageMime } from './store.js'; import { createStore, imageMime } from './store.js';
import { looksLikePlaceholder } from './image.js'; import { looksLikePlaceholder } from './image.js';
import { createAuth, readCookie, cookieHeader } from './auth.js';
import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js'; import { workingDays, generateSchedule, isValidDate, countAssignments, addDays } from './scheduler.js';
import { germanHolidays, BUNDESLAENDER } from './holidays.js'; import { germanHolidays, BUNDESLAENDER } from './holidays.js';
import { buildEventLines, buildCalendar, eventUid } from './ics.js'; import { buildEventLines, buildCalendar, eventUid } from './ics.js';
@@ -46,6 +47,45 @@ if (appPassword) {
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, '..', 'public'))); 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 S = () => store.state;
const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max); const clean = (s, max = 120) => String(s ?? '').trim().slice(0, max);
const bad = (res, msg) => res.status(400).json({ error: msg }); const bad = (res, msg) => res.status(400).json({ error: msg });
@@ -85,7 +125,24 @@ function publicPerson(p) {
// ---------- Gesamtzustand ---------- // ---------- Gesamtzustand ----------
app.get('/api/state', (req, res) => { app.get('/api/state', (req, res) => {
const st = S(); 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({ res.json({
auth: authInfo,
persons: st.persons.map(publicPerson), persons: st.persons.map(publicPerson),
absences: st.absences, absences: st.absences,
holidays: st.holidays, holidays: st.holidays,
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createAuth, readCookie, cookieHeader, COOKIE_NAME } from '../server/auth.js';
test('ohne Passwort ist der Schutz aus', () => {
const a = createAuth({ password: '', secret: 's' });
assert.equal(a.enabled, false);
assert.equal(a.checkPassword(''), false);
});
test('Passwortprüfung und signierte Token', () => {
let t = 1_000_000;
const a = createAuth({ password: 'geheim', secret: 'schluessel', now: () => t });
assert.ok(a.enabled);
assert.ok(a.checkPassword('geheim'));
assert.ok(!a.checkPassword('Geheim'));
assert.ok(!a.checkPassword(undefined));
const token = a.issue();
assert.ok(a.verify(token));
assert.ok(!a.verify(token + 'x'), 'manipulierte Signatur');
assert.ok(!a.verify('123.abc'));
assert.ok(!a.verify(''));
const other = createAuth({ password: 'geheim', secret: 'anderer', now: () => t });
assert.ok(!other.verify(token), 'anderer Schlüssel');
t += a.ttlMs + 1;
assert.ok(!a.verify(token), 'abgelaufen');
});
test('Bremse nach fünf Fehlversuchen', () => {
let t = 0;
const a = createAuth({ password: 'x', secret: 's', now: () => t });
for (let i = 0; i < 4; i += 1) a.noteFailure('1.2.3.4');
assert.equal(a.blockedFor('1.2.3.4'), 0);
a.noteFailure('1.2.3.4');
assert.ok(a.blockedFor('1.2.3.4') > 0);
assert.equal(a.blockedFor('5.6.7.8'), 0);
t += 61_000;
assert.equal(a.blockedFor('1.2.3.4'), 0);
a.noteFailure('1.2.3.4');
a.noteSuccess('1.2.3.4');
assert.equal(a.blockedFor('1.2.3.4'), 0);
});
test('Cookie lesen und schreiben', () => {
const header = cookieHeader('abc.def', { secure: true });
assert.ok(header.startsWith(`${COOKIE_NAME}=abc.def; Path=/; HttpOnly; SameSite=Lax; Max-Age=`));
assert.ok(header.endsWith('; Secure'));
assert.equal(readCookie(`foo=1; ${COOKIE_NAME}=abc.def; bar=2`), 'abc.def');
assert.equal(readCookie('foo=1'), null);
assert.equal(readCookie(undefined), null);
});