Write the notes in an app, a school day at a time

The notes existed but there was nowhere to write them: a CLI command on a
laptop, a tool call through Claude, or a file in a Docker volume. None of
those is reachable from a phone in a lesson, which is where notes are
actually taken.

So: `/app`, served only when WEB_PASSWORD is set. A login, the day's
notes, and a settings page for the Schulcloud token — the one surface
here meant for a person rather than a program.

The shape follows how the notes are written: one note per school day,
one `##` heading per lesson, prose and lists and tables beneath. That
turns out to be the design decision that matters, twice over.

First, it is what lets WebUntis earn its keep. Opening a day with no
note fills in that day's lessons — numbered, with times, teacher and
room, cancellations dropped and substitutions marked. Retyping the
timetable is exactly the work the second upstream exists to avoid, and
"Stunden ergänzen" tops up a note started before the day ended without
touching what is already written.

Second, it changes how notes are indexed. A day note is indexed per
lesson, not whole: search answers "my own note, Deutsch, 18.09.2026"
rather than "my own note, Friday", and `list_notes subject=Deutsch`
finds a day whose frontmatter names no subject at all. Indexed whole,
every hit would read as a weekday and "what did we do in Deutsch" would
match notes whose other five lessons were something else. `lessonHeading`
and `subjectFromHeading` are a loop — the app writes the heading, the
indexer reads the subject back out — and a test holds them to it.

Notes taken in a lesson cannot be retaken, so the editor is built
around not losing them: autosave, every keystroke mirrored to local
storage, a save when the phone locks, and a fallback to the local copy
when the request never arrives. A save that would overwrite a version
the editor never saw is refused and the choice handed back — the notes
folder is synced and open in more than one place, and a phone must not
silently win over a laptop. `replaceNote` is separate from `writeNote`
for that reason: never-overwrite is right for `add_note` and exactly
wrong for an editor.

WEB_PASSWORD is the first credential here a human types, so it is the
first that can be guessed: scrypt at startup, never stored or compared
in the clear, per-address rate limiting — which is not decoration, since
the scrypt cost is itself a denial-of-service vector without it. The
session is a signed HttpOnly SameSite=Strict cookie whose key is derived
from the password, so changing it logs everyone out and there is no
second secret to keep. It opens /api, because a session is the user, and
never /mcp, because nothing in a browser speaks MCP.

Also here, because the app made them matter: frontmatter now reads the
indented `- item` list form editors write, so an Obsidian vault
round-trips its tags; and a four-digit folder is a filing scheme, not a
subject, so `2026/` does not file a school year under one.

357 tests; 106/107 smoke against the local instance, the one failure
being the H5P service that instance does not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-19 17:21:17 +02:00
parent af4464decb
commit dc50b4bcd5
29 changed files with 2501 additions and 144 deletions

View File

@@ -12,7 +12,18 @@ import {
type FsErrorCode,
type WalkEntry,
} from '../core/legacy-files.ts';
import { filterNotes, NoteNotFound, readNoteAt, readNotes, writeNote } from '../core/notes.ts';
import { dayLessons, dayNoteSkeleton, dayNoteTitle, missingHeadings } from '../core/day-note.ts';
import { isCalendarDate, schoolToday } from '../core/dates.ts';
import {
dayNotePath,
NoteConflict,
NoteNotFound,
filterNotes,
readNoteAt,
readNotes,
replaceNote,
writeNote,
} from '../core/notes.ts';
import { resolveWithin } from '../core/paths.ts';
import { TokenRejected } from '../core/session-token.ts';
import type { Services } from '../services.ts';
@@ -307,6 +318,94 @@ export function createApiRouter(services: Services): Router {
}
});
// --- one school day, as the notes page edits it -------------------------
//
// The page is a Markdown editor for a single file, so these two are `GET the
// day` and `PUT the day`. What makes them worth their own routes rather than
// the generic ones above is the skeleton: WebUntis is the only thing that
// knows which lessons a day held, and handing someone their day already laid
// out is the difference between a note per day and an empty box.
router.get('/notes/day', async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
const date = stringParam(req.query.date) ?? schoolToday();
if (!isCalendarDate(date)) {
return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date}.` });
}
try {
const path = dayNotePath(date);
const note = await readNoteAt(root, path).catch((error: unknown) => {
if (error instanceof NoteNotFound) return undefined;
throw error;
});
// Never fatal, and reported rather than hidden: without a key, or with
// WebUntis down, the page still has to open — it just cannot offer the
// lessons, and saying so beats an empty skeleton that looks like a day
// with no school.
let lessons: ReturnType<typeof dayLessons> = [];
let timetable: 'ok' | 'off' | 'unavailable' = services.untis ? 'ok' : 'off';
if (services.untis) {
try {
lessons = dayLessons(await services.untis.timetable(date, date), date);
} catch {
timetable = 'unavailable';
}
}
return res.json({
date,
path,
title: dayNoteTitle(date),
exists: Boolean(note),
text: note?.text ?? '',
modifiedAt: note?.modifiedAt ?? null,
timetable,
lessons,
skeleton: dayNoteSkeleton(lessons),
// What the page would add to a note already started, so "top up the
// day" never rewrites what is there.
missing: note ? dayNoteSkeleton(missingHeadings(note.text, lessons)) : '',
});
} catch (error) {
return fail(res, error, 'read a day note');
}
});
router.put('/notes/day', express.json({ limit: '2mb' }), async (req: Request, res: Response) => {
const root = services.config.notesDir;
if (!root) return res.status(503).json({ error: 'no_notes_dir', message: NO_NOTES_DIR });
if (!services.config.notesWritable) {
return res.status(403).json({ error: 'notes_readonly', message: 'This server was started with NOTES_READONLY.' });
}
const body = (req.body ?? {}) as { date?: unknown; text?: unknown; expectedModifiedAt?: unknown };
const date = typeof body.date === 'string' ? body.date : '';
if (!isCalendarDate(date)) {
return res.status(400).json({ error: 'invalid', message: `Not a date in the calendar: ${date || '(none)'}.` });
}
if (typeof body.text !== 'string') {
return res.status(400).json({ error: 'invalid', message: 'A day note needs its text.' });
}
try {
const note = await replaceNote(
root,
dayNotePath(date),
{ title: dayNoteTitle(date), text: body.text, date, source: 'notes-page' },
typeof body.expectedModifiedAt === 'string' ? { expectedModifiedAt: body.expectedModifiedAt } : {},
);
return res.json({ path: note.path, modifiedAt: note.modifiedAt, bytes: note.bytes });
} catch (error) {
// A clash is the caller's to resolve, not a fault: the page shows both
// and lets the person decide, which is the only safe answer when the
// notes folder is synced and open in two places.
if (error instanceof NoteConflict) {
return res.status(409).json({ error: 'conflict', message: error.message, modifiedAt: error.modifiedAt });
}
return fail(res, error, 'save a day note');
}
});
router.get('/token', (_req: Request, res: Response) => {
res.json(tokenStatus(services));
});

135
src/http/app-page.ts Normal file
View File

@@ -0,0 +1,135 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import express, { type Request, type Response, type Router } from 'express';
import type { Config } from '../config.ts';
import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web-auth.ts';
/**
* `/app` — the notes app, for a person rather than a program.
*
* Everything else this server exposes is for a machine with a token. This is
* the one surface a human opens on a phone, so it gets a login, a session
* cookie and an interface: the day's notes, and the settings page where the
* Schulcloud token is replaced when it expires.
*
* It is served only when `WEB_PASSWORD` is set, by the same rule as the
* `untis_*` tools and the note tools: an app whose login nothing can open is
* worse than no app, because it looks like a way in.
*
* The assets are files, not strings in this module. They are real HTML, CSS
* and JavaScript that an editor and a linter understand, and the content
* security policy forbids inline script anyway — so the only thing gained by
* embedding them would be a build step that no longer copies them, and the
* only thing lost would be every tool that reads them.
*/
/** No outside resources at all, and no inline script. Nothing here needs either. */
const CSP =
"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
"connect-src 'self'; manifest-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
const HEADERS: Record<string, string> = {
'Content-Security-Policy': CSP,
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
// The app reflects an account's data; a shared phone should not show it from
// the back-forward cache after a logout.
'Cache-Control': 'no-store',
};
const ASSETS: Record<string, { file: string; type: string }> = {
'/': { file: 'index.html', type: 'text/html; charset=utf-8' },
'/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
'/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' },
'/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
'/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
'/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' },
};
/**
* Read once at startup, from next to this module.
*
* `import.meta.dirname` resolves to `src/http` when the tree is run directly
* and `dist/http` after a build, and `scripts/copy-assets.mjs` puts the folder
* in both — so there is one path and no branch on how the server was started.
*/
const assetRoot = join(import.meta.dirname, 'app');
const cache = new Map<string, Buffer>();
function asset(file: string): Buffer {
let bytes = cache.get(file);
if (!bytes) {
bytes = readFileSync(join(assetRoot, file));
cache.set(file, bytes);
}
return bytes;
}
export interface AppSurface {
router: Router;
/** The gate `/api` also accepts, so the app's own fetches need no token. */
auth: WebAuth;
}
export function createAppRouter(config: Config): AppSurface | undefined {
const auth = createWebAuth(config.webPassword);
if (!auth.enabled) return undefined;
const router = express.Router();
const requireSession = sessionAuth(auth);
router.use((_req: Request, res: Response, next) => {
for (const [name, value] of Object.entries(HEADERS)) res.setHeader(name, value);
next();
});
// The shell is public: it is the login screen, and it holds nothing. Every
// byte of data it goes on to show comes from /api, behind the session.
router.get(/^\/(index\.html|app\.css|app\.js|icon\.svg|manifest\.webmanifest)?$/, (req: Request, res: Response) => {
const entry = ASSETS[req.path] ?? ASSETS['/']!;
res.type(entry.type).send(asset(entry.file));
});
router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => {
const password = (req.body as { password?: unknown } | undefined)?.password;
if (typeof password !== 'string' || password.length === 0) {
return res.status(400).json({ error: 'invalid', message: 'Passwort fehlt.' });
}
// The address is the rate-limit key. Behind Caddy every request comes from
// the proxy, so the forwarded address is what distinguishes callers; it is
// spoofable by anyone who can reach this process directly, which on this
// deployment is nobody.
const from = (req.get('x-forwarded-for') ?? '').split(',')[0]?.trim() || req.ip || 'unknown';
const result = auth.check(password, from);
if (!result.ok) {
if (result.retryAfterSeconds !== undefined) {
res.setHeader('Retry-After', String(result.retryAfterSeconds));
return res.status(429).json({
error: 'too_many_attempts',
message: `Zu viele Fehlversuche. In ${Math.ceil(result.retryAfterSeconds / 60)} Minute(n) erneut versuchen.`,
});
}
// Deliberately no detail, and the same shape for every miss.
return res.status(401).json({ error: 'unauthorized' });
}
res.setHeader('Set-Cookie', auth.cookie(auth.mint(), { secure: isSecureRequest(req) }));
return res.json({ authenticated: true });
});
router.post('/logout', (req: Request, res: Response) => {
res.setHeader('Set-Cookie', auth.clearCookie({ secure: isSecureRequest(req) }));
return res.json({ authenticated: false });
});
// Always 200: "are you logged in" is not itself a protected question, and a
// 401 here would make the first load of the login screen look like an error.
router.get('/session', (req: Request, res: Response) => {
return res.json({ authenticated: auth.verify(req.get('cookie')) });
});
// Anything else under /app needs the session — there is nothing else to
// serve, but a 404 that leaks the shape of the tree is still a 404 too many.
router.use(requireSession, (_req: Request, res: Response) => res.status(404).json({ error: 'not_found' }));
return { router, auth };
}

188
src/http/app/app.css Normal file
View File

@@ -0,0 +1,188 @@
/*
* The app is used with one thumb, in a lesson, on a phone that may be at 10%.
* Everything below follows from that: one column, large touch targets, the
* editor taking every pixel that is not navigation, and no webfont — the CSP
* forbids outside resources anyway, and a font that has not loaded is a blank
* screen in a classroom with no signal.
*/
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1f2328;
--muted: #656d76;
--line: #d0d7de;
--accent: #1f6feb;
--ok: #1a7f37;
--error: #cf222e;
--warn: #9a6700;
--card: #f6f8fa;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #8b949e;
--line: #30363d;
--accent: #4493f8;
--ok: #3fb950;
--error: #f85149;
--warn: #d29922;
--card: #161b22;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
/* Fills the viewport on a phone, where 100vh lies about the toolbar. */
min-height: 100dvh;
display: flex;
flex-direction: column;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
.screen { display: flex; flex-direction: column; flex: 1; min-height: 0; }
[hidden] { display: none !important; }
/* --- chrome ------------------------------------------------------------ */
header { border-bottom: 1px solid var(--line); }
.tabs { display: flex; }
.tab {
flex: 1;
padding: 0.9rem 0.5rem;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--muted);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.tab[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 0.75rem; gap: 0.5rem; }
/* --- the day bar ------------------------------------------------------- */
.daybar { display: flex; align-items: center; gap: 0.5rem; }
.daybar button {
flex: 0 0 auto;
width: 2.75rem;
height: 2.75rem;
font-size: 1.5rem;
line-height: 1;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--card);
color: var(--fg);
cursor: pointer;
}
.daybar-centre { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
.daybar-centre strong { font-size: 1.05rem; }
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; }
/* --- the editor -------------------------------------------------------- */
textarea {
flex: 1;
min-height: 12rem;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
/* Monospace: the notes are Markdown, and headings and list markers have to
line up to be read back as structure. */
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
line-height: 1.5;
resize: none;
}
.actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
button {
padding: 0.65rem 1rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--card);
color: var(--fg);
font: inherit;
cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
#save, #login-form button, #token-form button {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
font-weight: 600;
}
/* --- text -------------------------------------------------------------- */
.status { margin: 0; color: var(--muted); font-size: 0.85rem; min-height: 1.2em; }
.hint { color: var(--muted); font-size: 0.85rem; }
.ok { color: var(--ok); }
.error { color: var(--error); margin: 0.5rem 0 0; }
.warn { color: var(--warn); }
.conflict {
margin: 0;
padding: 0.6rem 0.75rem;
border: 1px solid var(--warn);
border-radius: 0.5rem;
color: var(--warn);
font-size: 0.9rem;
}
/* --- cards (login, settings) ------------------------------------------- */
.card {
margin: 0.75rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--card);
}
.card h1, .card h2 { margin-top: 0; font-size: 1.15rem; }
label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; font-size: 0.9rem; }
input[type="password"], input[type="text"] {
width: 100%;
padding: 0.7rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font: inherit;
}
#login { justify-content: center; }
#login .card { width: min(24rem, 100%); align-self: center; }
#login button { width: 100%; margin-top: 1rem; }
.steps { margin: 0.5rem 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
.steps code { font-family: ui-monospace, monospace; }
#token-form button, #logout { margin-top: 0.75rem; }
dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.75rem; font-size: 0.9rem; }
dt { color: var(--muted); }
dd { margin: 0; }

490
src/http/app/app.js Normal file
View File

@@ -0,0 +1,490 @@
'use strict';
/*
* The notes app.
*
* One school day is one note, one lesson is one `##` heading, and the server
* builds the headings from WebUntis — so opening the app during a free period
* gives you the day already laid out rather than an empty box. That shape is
* also what makes each lesson separately searchable afterwards, which is the
* whole reason the notes are worth writing here rather than in Notes.app.
*
* Three rules this file exists to honour:
*
* - **Never lose what was typed.** Every keystroke goes to localStorage, and a
* draft that is newer than the server's copy survives a dead connection, a
* locked phone and a closed tab. A note taken in a lesson cannot be retaken.
* - **Never silently overwrite.** Saves carry the modification time the editor
* loaded; the server refuses one that would clobber a version this editor
* never saw, and the banner then makes it the person's decision.
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
* "Offline — lokal gesichert". A silent editor over a flaky connection is
* indistinguishable from one that is losing your work.
*/
const AUTOSAVE_MS = 2500;
const DRAFT_PREFIX = 'schulcloud-mcp/draft/';
const ui = {
login: document.getElementById('login'),
loginForm: document.getElementById('login-form'),
password: document.getElementById('password'),
loginError: document.getElementById('login-error'),
app: document.getElementById('app'),
tabNotes: document.getElementById('tab-notes'),
tabSettings: document.getElementById('tab-settings'),
viewNotes: document.getElementById('view-notes'),
viewSettings: document.getElementById('view-settings'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
dayTitle: document.getElementById('day-title'),
dayDate: document.getElementById('day-date'),
dayStatus: document.getElementById('day-status'),
conflict: document.getElementById('day-conflict'),
editor: document.getElementById('editor'),
save: document.getElementById('save'),
fill: document.getElementById('fill'),
lessonsHint: document.getElementById('lessons-hint'),
tokenState: document.getElementById('token-state'),
tokenForm: document.getElementById('token-form'),
jwt: document.getElementById('jwt'),
tokenResult: document.getElementById('token-result'),
serverState: document.getElementById('server-state'),
logout: document.getElementById('logout'),
};
/** Everything about the day currently open. */
const day = {
date: today(),
path: '',
/** The server's modification time for the loaded note, or null if there is none. */
modifiedAt: null,
/** The text as the server last confirmed it, to tell "dirty" from "saved". */
saved: '',
/** Headings the timetable has and the note does not. */
missing: '',
dirty: false,
conflicted: false,
timer: 0,
};
// --- plumbing ------------------------------------------------------------
async function api(path, options) {
const response = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { accept: 'application/json', ...(options && options.headers) },
});
if (response.status === 401) {
showLogin();
throw new Error('unauthorized');
}
let body = null;
try {
body = await response.json();
} catch (error) {
body = null;
}
if (!response.ok) {
const failure = new Error((body && (body.message || body.error)) || 'HTTP ' + response.status);
failure.status = response.status;
failure.body = body;
throw failure;
}
return body;
}
function json(method, path, payload) {
return api(path, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
}
function today() {
// The device's own date. The server keeps school dates in Europe/Berlin, but
// the phone in the lesson is in that timezone by definition.
const now = new Date();
return [now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate())].join('-');
}
function pad(value) {
return String(value).padStart(2, '0');
}
function shiftDate(date, days) {
// Noon, so a daylight-saving change cannot push the result onto the
// neighbouring day.
const at = new Date(date + 'T12:00:00');
at.setDate(at.getDate() + days);
return [at.getFullYear(), pad(at.getMonth() + 1), pad(at.getDate())].join('-');
}
function clock() {
const now = new Date();
return pad(now.getHours()) + ':' + pad(now.getMinutes());
}
// --- drafts: the safety net ---------------------------------------------
function draftKey(date) {
return DRAFT_PREFIX + date;
}
function saveDraft() {
try {
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() }));
} catch (error) {
// A full or disabled localStorage must not break typing; the server copy
// is still the real one.
}
}
function readDraft(date) {
try {
const raw = localStorage.getItem(draftKey(date));
return raw ? JSON.parse(raw) : null;
} catch (error) {
return null;
}
}
function clearDraft(date) {
try {
localStorage.removeItem(draftKey(date));
} catch (error) {
// Nothing to do: a stale draft is only ever offered, never forced.
}
}
// --- the day -------------------------------------------------------------
function setStatus(message, kind) {
ui.dayStatus.textContent = message;
ui.dayStatus.className = 'status' + (kind ? ' ' + kind : '');
}
async function loadDay(date) {
// Anything unsaved goes to the draft before the view moves, or switching
// days would be a way to lose a lesson.
if (day.dirty) saveDraft();
window.clearTimeout(day.timer);
day.date = date;
day.conflicted = false;
ui.conflict.hidden = true;
ui.dayDate.value = date;
ui.editor.disabled = true;
setStatus('Wird geladen …');
let info;
try {
info = await api('/api/notes/day?date=' + encodeURIComponent(date));
} catch (error) {
if (error.message === 'unauthorized') return;
ui.dayTitle.textContent = date;
// A reply with a status is the server saying no — most often that it keeps
// no notes at all — and reporting that as "offline" would send someone
// looking at their signal instead of at NOTES_DIR.
if (error.status) {
ui.editor.value = '';
ui.editor.disabled = true;
setStatus(error.message, 'error');
ui.lessonsHint.textContent = '';
return;
}
// No status: the request never arrived. Fall back to whatever this device
// has, rather than an empty editor that looks like a day with no notes.
const draft = readDraft(date);
ui.editor.disabled = false;
ui.editor.value = draft ? draft.text : '';
day.saved = '';
day.modifiedAt = null;
day.dirty = Boolean(draft);
setStatus(
draft ? 'Offline — lokale Fassung, nicht gespeichert.' : 'Offline — keine Verbindung zum Server.',
'warn',
);
return;
}
day.path = info.path;
day.modifiedAt = info.modifiedAt;
day.missing = info.missing || '';
ui.dayTitle.textContent = info.title;
const server = info.exists ? info.text : info.skeleton;
const draft = readDraft(date);
// A draft only wins when it differs from what the server holds; otherwise it
// is just the last save echoed back and offering it would be noise.
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
ui.editor.value = useDraft ? draft.text : server;
ui.editor.disabled = false;
day.saved = info.exists ? info.text : '';
day.dirty = ui.editor.value !== day.saved;
if (useDraft) {
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
} else if (info.exists) {
setStatus('Gespeichert.');
} else if (info.skeleton) {
setStatus('Neuer Tag — Stunden aus WebUntis eingetragen.');
} else {
setStatus('Neuer Tag.');
}
describeLessons(info);
ui.fill.hidden = !day.missing;
}
function describeLessons(info) {
if (info.timetable === 'off') {
ui.lessonsHint.textContent = 'Ohne WebUntis-Schlüssel: Überschriften selbst anlegen.';
return;
}
if (info.timetable === 'unavailable') {
ui.lessonsHint.textContent = 'WebUntis nicht erreichbar — Stunden fehlen.';
return;
}
const count = (info.lessons || []).length;
ui.lessonsHint.textContent = count === 0 ? 'Kein Unterricht an diesem Tag.' : count + ' Stunde(n) laut Stundenplan.';
}
function markDirty() {
day.dirty = ui.editor.value !== day.saved;
saveDraft();
if (day.conflicted) return;
if (day.dirty) setStatus('Nicht gespeichert …');
window.clearTimeout(day.timer);
day.timer = window.setTimeout(() => void saveDay(true), AUTOSAVE_MS);
}
async function saveDay(automatic) {
window.clearTimeout(day.timer);
if (!day.dirty && automatic) return;
const text = ui.editor.value;
setStatus('Wird gespeichert …');
try {
const result = await json('PUT', '/api/notes/day', {
date: day.date,
text,
// Absent for a note that does not exist yet: there is nothing to clash
// with, and sending null would look like "I saw no version".
...(day.modifiedAt ? { expectedModifiedAt: day.modifiedAt } : {}),
});
day.saved = text;
day.modifiedAt = result.modifiedAt;
day.dirty = false;
day.conflicted = false;
ui.conflict.hidden = true;
clearDraft(day.date);
setStatus('Gespeichert ' + clock() + '.', 'ok');
} catch (error) {
if (error.message === 'unauthorized') return;
if (error.status === 409) {
// Stop autosaving: every further attempt would fail the same way, and
// the choice of which version wins is not ours to make.
day.conflicted = true;
ui.conflict.hidden = false;
ui.conflict.textContent =
'Diese Notiz wurde anderswo geändert, seit sie hier geöffnet wurde. ' +
'„Neu laden" verwirft, was hier steht; „Trotzdem speichern" überschreibt die andere Fassung. ' +
'Deine Fassung ist lokal gesichert.';
ensureConflictButtons();
setStatus('Nicht gespeichert — Konflikt.', 'error');
return;
}
if (error.status === 403) {
setStatus('Der Server nimmt keine Änderungen an (NOTES_READONLY).', 'error');
return;
}
setStatus('Nicht gespeichert — ' + error.message + '. Lokal gesichert.', 'error');
}
}
/** The two ways out of a conflict, added once and only when one happens. */
function ensureConflictButtons() {
if (document.getElementById('conflict-reload')) return;
const reload = document.createElement('button');
reload.id = 'conflict-reload';
reload.type = 'button';
reload.textContent = 'Neu laden';
reload.addEventListener('click', () => {
clearDraft(day.date);
void loadDay(day.date);
});
const force = document.createElement('button');
force.id = 'conflict-force';
force.type = 'button';
force.textContent = 'Trotzdem speichern';
force.addEventListener('click', () => {
day.modifiedAt = null;
day.conflicted = false;
ui.conflict.hidden = true;
void saveDay(false);
});
ui.conflict.append(document.createElement('br'), reload, document.createTextNode(' '), force);
}
// --- settings ------------------------------------------------------------
async function loadSettings() {
ui.tokenState.textContent = 'Wird geladen …';
try {
const info = await api('/api/token');
const budget = info.keepalive && info.keepalive.budgetSeconds;
ui.tokenState.textContent =
'Noch ' + info.daysLeft + ' Tag(e) gültig' +
(budget ? ', Sitzung noch ' + Math.round(budget / 60) + ' min' : '') +
' (' + info.source + ').';
ui.tokenState.className = 'status' + (info.daysLeft <= 3 ? ' warn' : '');
} catch (error) {
if (error.message === 'unauthorized') return;
ui.tokenState.textContent = 'Token-Status nicht lesbar: ' + error.message;
ui.tokenState.className = 'status error';
}
ui.serverState.replaceChildren();
try {
const status = await api('/api/status');
addFact('Index', status.crawlId ? 'Stand ' + status.crawlId + ', ' + status.nodes + ' Einträge' : 'leer');
addFact('Dateien', status.files + ' (' + status.extracted + ' mit Text)');
if (status.indexer && status.indexer.running) addFact('Gerade', 'Durchlauf läuft');
} catch (error) {
addFact('Index', 'nicht verfügbar');
}
try {
const notes = await api('/api/notes?limit=1');
addFact('Notizen', notes.count + ' · ' + notes.root + (notes.writable ? '' : ' (schreibgeschützt)'));
} catch (error) {
addFact('Notizen', 'nicht verfügbar');
}
}
function addFact(term, value) {
const dt = document.createElement('dt');
dt.textContent = term;
const dd = document.createElement('dd');
dd.textContent = value;
ui.serverState.append(dt, dd);
}
// --- views ---------------------------------------------------------------
function showLogin() {
ui.app.hidden = true;
ui.login.hidden = false;
ui.password.focus();
}
function showApp() {
ui.login.hidden = true;
ui.app.hidden = false;
}
function showTab(name) {
const notes = name !== 'settings';
ui.viewNotes.hidden = !notes;
ui.viewSettings.hidden = notes;
ui.tabNotes.setAttribute('aria-current', notes ? 'page' : 'false');
ui.tabSettings.setAttribute('aria-current', notes ? 'false' : 'page');
if (!notes) void loadSettings();
}
// --- wiring --------------------------------------------------------------
ui.loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
ui.loginError.textContent = '';
try {
await json('POST', '/app/login', { password: ui.password.value });
ui.password.value = '';
showApp();
await loadDay(day.date);
} catch (error) {
ui.loginError.textContent =
error.status === 429 ? 'Zu viele Versuche. ' + error.message : 'Passwort falsch.';
}
});
ui.logout.addEventListener('click', async () => {
// The draft stays: logging out is not the same as discarding a lesson.
await json('POST', '/app/logout', {}).catch(() => {});
showLogin();
});
ui.tabNotes.addEventListener('click', () => showTab('notes'));
ui.tabSettings.addEventListener('click', () => showTab('settings'));
ui.prev.addEventListener('click', () => void loadDay(shiftDate(day.date, -1)));
ui.next.addEventListener('click', () => void loadDay(shiftDate(day.date, 1)));
ui.dayDate.addEventListener('change', () => {
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
});
ui.editor.addEventListener('input', markDirty);
ui.save.addEventListener('click', () => void saveDay(false));
ui.fill.addEventListener('click', () => {
// Appended, never merged into place: the person's own text is not something
// to reorder, and a heading in the wrong order is trivial to move.
const separator = ui.editor.value.trim() ? '\n\n' : '';
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
day.missing = '';
ui.fill.hidden = true;
markDirty();
});
// A phone locking, the app going to the background, or the tab closing: all of
// them end the session without a "save" ever being pressed.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && day.dirty) {
saveDraft();
if (!day.conflicted) void saveDay(true);
}
});
window.addEventListener('beforeunload', (event) => {
if (!day.dirty) return;
saveDraft();
event.preventDefault();
event.returnValue = '';
});
ui.tokenForm.addEventListener('submit', async (event) => {
event.preventDefault();
ui.tokenResult.textContent = 'Wird geprüft …';
ui.tokenResult.className = 'status';
try {
const result = await json('PUT', '/api/token', { jwt: ui.jwt.value });
ui.jwt.value = '';
ui.tokenResult.textContent = result.changed
? 'Ersetzt. Noch ' + result.daysLeft + ' Tag(e) gültig.' + (result.persisted ? '' : ' (Nicht dauerhaft gespeichert.)')
: 'Das ist der Token, der bereits benutzt wird.';
ui.tokenResult.className = 'status ok';
void loadSettings();
} catch (error) {
if (error.message === 'unauthorized') return;
ui.tokenResult.textContent = error.message;
ui.tokenResult.className = 'status error';
}
});
// --- start ---------------------------------------------------------------
void (async () => {
try {
const session = await api('/app/session');
if (!session.authenticated) {
showLogin();
return;
}
} catch (error) {
showLogin();
return;
}
showApp();
await loadDay(day.date);
})();

6
src/http/app/icon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Notizen">
<rect width="64" height="64" rx="14" fill="#1f6feb"/>
<g fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round">
<path d="M18 20h28M18 32h28M18 44h18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 277 B

87
src/http/app/index.html Normal file
View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#1f6feb">
<title>Schulcloud — Notizen</title>
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="manifest" href="manifest.webmanifest">
<link rel="stylesheet" href="app.css">
</head>
<body>
<noscript>Diese Seite braucht JavaScript.</noscript>
<!-- Login. Shown until /app/session says otherwise; everything else stays hidden. -->
<section id="login" class="screen" hidden>
<form id="login-form" class="card">
<h1>Anmelden</h1>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required autofocus>
<button type="submit">Anmelden</button>
<p id="login-error" class="error" role="alert" aria-live="assertive"></p>
</form>
</section>
<div id="app" class="screen" hidden>
<header>
<nav class="tabs">
<button type="button" id="tab-notes" class="tab" aria-current="page">Notizen</button>
<button type="button" id="tab-settings" class="tab">Einstellungen</button>
</nav>
</header>
<!-- Notes: one school day per note, one heading per lesson. -->
<main id="view-notes" class="view">
<div class="daybar">
<button type="button" id="prev" aria-label="Vorheriger Tag"></button>
<div class="daybar-centre">
<strong id="day-title"></strong>
<input id="day-date" type="date" aria-label="Datum">
</div>
<button type="button" id="next" aria-label="Nächster Tag"></button>
</div>
<p id="day-status" class="status" role="status" aria-live="polite"></p>
<p id="day-conflict" class="conflict" role="alert" hidden></p>
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
<div class="actions">
<button type="button" id="save">Speichern</button>
<button type="button" id="fill" hidden>Stunden ergänzen</button>
<span id="lessons-hint" class="hint"></span>
</div>
</main>
<!-- Settings: the Schulcloud token, and what the server is doing. -->
<main id="view-settings" class="view" hidden>
<section class="card">
<h2>Schulcloud-Token</h2>
<p id="token-state" class="status"></p>
<ol class="steps">
<li>In einem privaten Fenster bei der Schulcloud anmelden.</li>
<li>DevTools → Application → Cookies → Wert des Cookies <code>jwt</code> kopieren.</li>
<li>Hier einsetzen und speichern. Der Server prüft ihn erst bei der Schulcloud.</li>
<li><strong>Das private Fenster schließen</strong> — offen gelassen meldet es den Token nach etwa zwei Stunden ab.</li>
</ol>
<form id="token-form">
<label for="jwt">Neuer jwt-Cookie</label>
<input id="jwt" type="password" autocomplete="off" spellcheck="false">
<button type="submit">Token ersetzen</button>
</form>
<p id="token-result" class="status" role="status" aria-live="polite"></p>
</section>
<section class="card">
<h2>Server</h2>
<dl id="server-state"></dl>
<button type="button" id="logout">Abmelden</button>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
{
"name": "Schulcloud Notizen",
"short_name": "Notizen",
"description": "Notizen zum Schultag, Stunde für Stunde.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#1f6feb",
"icons": [
{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}

View File

@@ -12,11 +12,25 @@ import type { NextFunction, Request, Response } from 'express';
*
* A route can accept more than one token: `/mcp` also takes the connector
* token claude.ai stores, which `/api` refuses.
*
* `alsoAccept` is the other kind of caller: a person logged into the web app,
* carrying a session cookie rather than a token. `/api` takes it because the
* app is built on `/api` and a session *is* the user; `/mcp` does not, because
* nothing in a browser speaks MCP and a surface not needed is a surface not
* offered.
*/
export function bearerAuth(accepted: string | string[]) {
export function bearerAuth(accepted: string | string[], alsoAccept?: (req: Request) => boolean) {
const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8'));
return function authenticate(req: Request, res: Response, next: NextFunction): void {
// A logged-in browser instead of a token. Checked first because the app's
// own fetches carry no Authorization header at all, and running them
// through the token comparison would only waste it.
if (alsoAccept?.(req)) {
next();
return;
}
const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token'));
// Every token is compared even after a match, so the timing does not
// tell which one was presented.

View File

@@ -6,6 +6,7 @@ import type { Config } from '../config.ts';
import { createServer } from '../mcp/server.ts';
import type { Services } from '../services.ts';
import { createApiRouter } from './api.ts';
import { createAppRouter } from './app-page.ts';
import { bearerAuth, pathSecret } from './auth.ts';
import { tokenPage, tokenScript } from './token-page.ts';
@@ -61,9 +62,17 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
// connector token opens /mcp alone. claude.ai stores it as a request header,
// and a credential held by a third party should reach the read-only tools,
// not /api, which can replace the Schulcloud token and stream the file mirror.
// The web app, when a password is configured. Mounted before the token gate
// so its login screen is reachable without one — it is the thing that issues
// the session everything else then accepts.
const appSurface = services ? createAppRouter(config) : undefined;
if (appSurface) app.use('/app', appSurface.router);
const loggedIn = appSurface ? (req: Request) => appSurface.auth.verify(req.get('cookie')) : undefined;
if (config.authToken) {
app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken));
app.use(API_PATH, bearerAuth(config.authToken));
app.use(API_PATH, bearerAuth(config.authToken, loggedIn));
} else {
console.warn(
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +

197
src/http/web-auth.ts Normal file
View File

@@ -0,0 +1,197 @@
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
/**
* A login for the web app, as opposed to a token for a machine.
*
* Everything else here authenticates a program: the CLI and Claude send a
* bearer token they were configured with. A person on a phone cannot be asked
* to paste a 64-character token into a browser every time they want to write
* down what happened in German, so the app gets a password and a session
* cookie — which is a different credential with a different lifetime, not a
* second way to present the same one.
*
* What that buys and what it costs:
*
* - **The password is never stored, compared or logged in the clear.** It is
* put through scrypt at startup and only the hash is kept; a login hashes
* the attempt and compares in constant time.
* - **The session key is derived from the password**, so changing the password
* invalidates every session that exists — which is the behaviour anyone
* changing a password expects, and it needs no second secret and no storage.
* - **The cookie is HttpOnly and SameSite=Strict**, so no script can read it
* and no other site can cause a request that carries it. That is what stands
* in for CSRF tokens here.
* - **Login is rate-limited per address**, because the endpoint is on the
* internet and a password is guessable in a way a 32-byte token is not. The
* scrypt cost is itself a brute-force defence and, without a limiter, a
* denial-of-service vector — so the limiter is not optional.
*/
export const SESSION_COOKIE = 'sc_app';
/** How long a login lasts. Long, because the alternative is logging in during a lesson. */
const SESSION_TTL_MS = 30 * 24 * 60 * 60_000;
/** scrypt parameters. N=16384 is ~50ms here — slow enough to matter, fast enough to log in. */
const SCRYPT = { N: 16_384, r: 8, p: 1, keylen: 32 };
/** Failed logins allowed from one address before it has to wait. */
const MAX_ATTEMPTS = 8;
const ATTEMPT_WINDOW_MS = 15 * 60_000;
export interface WebAuth {
/** True when a password is configured at all; without one the app is not served. */
readonly enabled: boolean;
check(password: string, from: string): { ok: boolean; retryAfterSeconds?: number };
mint(): string;
verify(cookieHeader: string | undefined): boolean;
cookie(value: string, options: { secure: boolean }): string;
clearCookie(options: { secure: boolean }): string;
}
/**
* Builds the app's authenticator from the configured password.
*
* The salt is fixed rather than random because the hash is never stored: it
* lives in this process only, and a random salt would merely mean the same
* password produced a different session key on every restart — logging
* everyone out whenever the Pi reboots.
*/
export function createWebAuth(password: string | undefined): WebAuth {
if (!password) {
return {
enabled: false,
check: () => ({ ok: false }),
mint: () => '',
verify: () => false,
cookie: () => '',
clearCookie: () => '',
};
}
const verifier = scryptSync(password, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT);
// A separate derivation, so a session cookie can never be used to test a
// password guess offline against the verifier.
const sessionKey = scryptSync(password, 'schulcloud-mcp/app/session', SCRYPT.keylen, SCRYPT);
const attempts = new Map<string, { count: number; first: number }>();
return {
enabled: true,
check(presented: string, from: string) {
const now = Date.now();
const record = attempts.get(from);
if (record && now - record.first > ATTEMPT_WINDOW_MS) attempts.delete(from);
const current = attempts.get(from);
if (current && current.count >= MAX_ATTEMPTS) {
return { ok: false, retryAfterSeconds: Math.ceil((ATTEMPT_WINDOW_MS - (now - current.first)) / 1000) };
}
const hashed = scryptSync(presented, 'schulcloud-mcp/app/verifier', SCRYPT.keylen, SCRYPT);
if (timingSafeEqual(hashed, verifier)) {
attempts.delete(from);
return { ok: true };
}
attempts.set(from, { count: (current?.count ?? 0) + 1, first: current?.first ?? now });
return { ok: false };
},
mint(): string {
const expires = Date.now() + SESSION_TTL_MS;
// A nonce so two logins never mint the same cookie; nothing reads it
// back, it only keeps the value unique.
const nonce = randomBytes(9).toString('base64url');
const body = `${expires}.${nonce}`;
return `${body}.${sign(body, sessionKey)}`;
},
verify(cookieHeader: string | undefined): boolean {
const value = readCookie(cookieHeader, SESSION_COOKIE);
if (!value) return false;
const cut = value.lastIndexOf('.');
if (cut <= 0) return false;
const body = value.slice(0, cut);
const presented = Buffer.from(value.slice(cut + 1), 'utf8');
const expected = Buffer.from(sign(body, sessionKey), 'utf8');
if (presented.length !== expected.length || !timingSafeEqual(presented, expected)) return false;
const expires = Number(body.split('.')[0]);
return Number.isFinite(expires) && expires > Date.now();
},
cookie(value: string, options: { secure: boolean }): string {
return [
`${SESSION_COOKIE}=${value}`,
'Path=/',
'HttpOnly',
// Strict, not Lax: nothing links into this app from elsewhere, and
// Strict is what removes cross-site requests as a category.
'SameSite=Strict',
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
options.secure ? 'Secure' : undefined,
]
.filter(Boolean)
.join('; ');
},
clearCookie(options: { secure: boolean }): string {
return [
`${SESSION_COOKIE}=`,
'Path=/',
'HttpOnly',
'SameSite=Strict',
'Max-Age=0',
options.secure ? 'Secure' : undefined,
]
.filter(Boolean)
.join('; ');
},
};
}
function sign(body: string, key: Buffer): string {
return createHmac('sha256', key).update(body).digest('base64url');
}
/** One cookie out of a `Cookie:` header, without a dependency. */
export function readCookie(header: string | undefined, name: string): string | undefined {
if (!header) return undefined;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq === -1) continue;
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
}
return undefined;
}
/**
* True when the request reached us over TLS.
*
* Behind Caddy the hop to this process is plain HTTP, so the header it sets is
* the only evidence — and marking the cookie Secure on a connection that is
* not would make it vanish, which looks exactly like a broken login.
*/
export function isSecureRequest(req: Request): boolean {
const forwarded = req.get('x-forwarded-proto');
if (forwarded) return forwarded.split(',')[0]!.trim() === 'https';
return req.protocol === 'https';
}
/** Gate for the app's own pages and for `/api` when the caller is a browser. */
export function sessionAuth(auth: WebAuth) {
return function requireSession(req: Request, res: Response, next: NextFunction): void {
if (auth.verify(req.get('cookie'))) {
next();
return;
}
// HTML gets the login screen, fetch() gets a 401 it can act on. Answering
// a fetch with a redirect to a page would hand the caller a chunk of HTML
// it cannot use and no way to tell what went wrong.
if (req.method === 'GET' && (req.get('accept') ?? '').includes('text/html')) {
res.redirect(302, '/app/');
return;
}
res.status(401).json({ error: 'unauthorized', message: 'Log in again.' });
};
}