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:
@@ -63,6 +63,15 @@ export interface Config {
|
||||
/** How often to re-crawl on a timer. Zero = only on demand. */
|
||||
crawlIntervalMs: number;
|
||||
|
||||
/**
|
||||
* Password for the web app at `/app` — the notes editor and the settings
|
||||
* page. Unset = the app is not served at all, by the same rule the untis_*
|
||||
* tools follow: a login screen no password can open is worse than no page.
|
||||
*
|
||||
* A credential, and the only one here a person types: it is hashed at
|
||||
* startup and the plain value is never compared, stored or logged.
|
||||
*/
|
||||
webPassword: string | undefined;
|
||||
/**
|
||||
* Where the user's own lesson notes live, as Markdown files. Unset = the
|
||||
* note tools are not offered, the same rule the untis_* tools follow.
|
||||
@@ -181,6 +190,26 @@ function untisConfig(): UntisConfig | undefined {
|
||||
return { server, school, user, secret };
|
||||
}
|
||||
|
||||
/**
|
||||
* The app password, or undefined when the app is switched off.
|
||||
*
|
||||
* A length floor and nothing else: this one is typed by a person on a phone,
|
||||
* so demanding punctuation would buy little and cost the thing that actually
|
||||
* matters, which is that they pick something long. The error states the rule
|
||||
* and never echoes the value.
|
||||
*/
|
||||
function webPassword(): string | undefined {
|
||||
const value = process.env.WEB_PASSWORD;
|
||||
if (!value) return undefined;
|
||||
if (value.length < 12) {
|
||||
throw new Error(
|
||||
'WEB_PASSWORD must be at least 12 characters — it is the only thing between the internet and the ' +
|
||||
'notes app. A passphrase of three or four words is ideal.',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||
function intAllowingZero(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
@@ -231,6 +260,7 @@ export function loadConfig(): Config {
|
||||
crawlIntervalMs: intAllowingZero('CRAWL_INTERVAL_MS', 6 * 60 * 60_000),
|
||||
// Absolute for the same reason as the mirror: resolveWithin only returns
|
||||
// an absolute path when the root it is given is one.
|
||||
webPassword: webPassword(),
|
||||
notesDir: process.env.NOTES_DIR?.trim() ? resolve(process.env.NOTES_DIR.trim()) : undefined,
|
||||
notesWritable: !bool('NOTES_READONLY', false),
|
||||
untisHistoryDays: intAllowingZero('UNTIS_HISTORY_DAYS', 180),
|
||||
|
||||
117
src/core/day-note.ts
Normal file
117
src/core/day-note.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { germanDay, germanWeekday } from './dates.ts';
|
||||
import type { UntisLesson, UntisTimetable } from './untis.ts';
|
||||
|
||||
/**
|
||||
* A school day as a note: one file, one heading per lesson.
|
||||
*
|
||||
* This is where the two systems meet for the third one. WebUntis is the only
|
||||
* place that knows which lessons a day actually holds — including that the
|
||||
* third period was cancelled and the fourth is a substitution — so a page that
|
||||
* asks someone to write up their day can hand them the day already laid out
|
||||
* instead of an empty box. The headings it writes are the ones
|
||||
* `subjectFromHeading` reads back, which is what makes each lesson separately
|
||||
* searchable afterwards.
|
||||
*/
|
||||
|
||||
export interface DayLesson {
|
||||
/** The heading text, without its `##`. */
|
||||
heading: string;
|
||||
subject?: string;
|
||||
start: string;
|
||||
end: string;
|
||||
periodId: number;
|
||||
/** A substitution: worth knowing while writing, since the teacher differs. */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
/** `Montag, 15.09.2026` — what a day note is called. */
|
||||
export function dayNoteTitle(date: string): string {
|
||||
return `${germanWeekday(date)}, ${germanDay(date)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One lesson's heading: `1. DE — 08:00–08:45 · Meier · R 204`.
|
||||
*
|
||||
* The subject leads, because that is the part a person scans for and the part
|
||||
* the parser reads back. Everything after the first `·` is context and may be
|
||||
* edited away without breaking anything.
|
||||
*/
|
||||
export function lessonHeading(lesson: UntisLesson, index: number): string {
|
||||
const subject = lesson.subjects[0];
|
||||
const name = subject?.longName || subject?.name || 'Stunde';
|
||||
const teachers = lesson.teachers.map((teacher) => teacher.name).join(', ');
|
||||
const rooms = lesson.rooms.map((room) => room.name).join(', ');
|
||||
return [
|
||||
`${index + 1}. ${name} — ${lesson.start}–${lesson.end}`,
|
||||
teachers || undefined,
|
||||
rooms ? `R ${rooms}` : undefined,
|
||||
lesson.changed ? 'Vertretung' : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's lessons, in order, as headings.
|
||||
*
|
||||
* Cancelled periods are left out: nothing was taught in them, and a heading
|
||||
* with nothing under it is worse than no heading. A substitution is kept and
|
||||
* marked, because it did happen and its teacher is not the usual one.
|
||||
*/
|
||||
export function dayLessons(timetable: UntisTimetable, date: string): DayLesson[] {
|
||||
const day = timetable.days.find((entry) => entry.date === date);
|
||||
const held = (day?.lessons ?? []).filter((lesson) => !lesson.cancelled);
|
||||
return held.map((lesson, index) => {
|
||||
const subject = lesson.subjects[0];
|
||||
return {
|
||||
heading: lessonHeading(lesson, index),
|
||||
...(subject?.longName || subject?.name ? { subject: subject!.longName || subject!.name } : {}),
|
||||
start: lesson.start,
|
||||
end: lesson.end,
|
||||
periodId: lesson.periodId,
|
||||
changed: lesson.changed,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The starting text for a day's note: a heading per lesson, blank beneath.
|
||||
*
|
||||
* Blank rather than prompted — a placeholder line would have to be deleted in
|
||||
* every lesson of every day, and half of them would survive into the note.
|
||||
*/
|
||||
export function dayNoteSkeleton(lessons: DayLesson[]): string {
|
||||
if (lessons.length === 0) return '';
|
||||
return `${lessons.map((lesson) => `## ${lesson.heading}\n`).join('\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The headings a note is missing, for a day whose timetable is known.
|
||||
*
|
||||
* Someone who starts a note before the day ends, or whose timetable changed
|
||||
* after they started, should be able to top it up without losing what they
|
||||
* wrote — so the page adds what is absent rather than rebuilding the note.
|
||||
*/
|
||||
export function missingHeadings(text: string, lessons: DayLesson[]): DayLesson[] {
|
||||
const present = new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((line) => /^##\s+(.*\S)\s*$/.exec(line)?.[1])
|
||||
.filter((heading): heading is string => Boolean(heading))
|
||||
.map(headingKey),
|
||||
);
|
||||
return lessons.filter((lesson) => !present.has(headingKey(lesson.heading)));
|
||||
}
|
||||
|
||||
/**
|
||||
* What makes two headings "the same lesson".
|
||||
*
|
||||
* The period number and the subject, ignoring everything a person may have
|
||||
* rewritten — a heading edited from `2. DE — 08:50–09:35 · Meier` down to
|
||||
* `2. Deutsch` is still the second period, and adding it again would give the
|
||||
* day two.
|
||||
*/
|
||||
function headingKey(heading: string): string {
|
||||
const match = /^\s*(\d{1,2})\s*[.)]/.exec(heading);
|
||||
return match ? `#${match[1]}` : heading.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CrawledBoard, Snapshot } from './crawl.ts';
|
||||
import { h5pSearchText } from './h5p.ts';
|
||||
import { noteSearchText } from './notes.ts';
|
||||
import { noteSearchText, noteSections } from './notes.ts';
|
||||
import { matchesAll, snippet, tokenize } from './text.ts';
|
||||
|
||||
/**
|
||||
@@ -119,6 +119,26 @@ export function searchSnapshot(snapshot: Snapshot, query: string, limit = 50): H
|
||||
// follows a note back to a course, and the subject is what makes the hit
|
||||
// readable — "Deutsch — my note" rather than a bare path.
|
||||
for (const note of snapshot.notes) {
|
||||
// Per lesson where the note has lessons, exactly as the index does it —
|
||||
// otherwise fresh=true would report "Monday" where the index reports
|
||||
// "Deutsch, Monday", and the two paths would disagree about the same file.
|
||||
const sections = noteSections(note);
|
||||
if (sections.length > 0) {
|
||||
for (const section of sections) {
|
||||
const haystack = [section.heading, section.text].filter(Boolean).join('\n');
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
courseId: note.courseId ?? '',
|
||||
courseTitle: section.subject ?? note.subject ?? 'Notizen',
|
||||
where: `my own note, ${section.heading}${note.date ? `, ${note.date}` : ''}`,
|
||||
targetId: note.path,
|
||||
targetKind: 'note',
|
||||
snippet: snippet(haystack, terms),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const haystack = noteSearchText(note);
|
||||
if (!matchesAll(haystack, terms)) continue;
|
||||
hits.push({
|
||||
|
||||
@@ -195,12 +195,20 @@ export function splitFrontmatter(raw: string): { front: NoteFrontmatter; body: s
|
||||
|
||||
const front: NoteFrontmatter = {};
|
||||
const extra: Record<string, string> = {};
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const lines = block.split(/\r?\n/);
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(.*)$/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const key = match[1]!.toLowerCase();
|
||||
const value = unquote(match[2]!.trim());
|
||||
if (!value) continue;
|
||||
let value = unquote(match[2]!.trim());
|
||||
// `tags:` followed by indented `- item` lines is how Obsidian and most
|
||||
// YAML front ends write a list, and reading only the inline `[a, b]`
|
||||
// form dropped every tag such an editor had written.
|
||||
if (!value) {
|
||||
const items = blockList(lines, index);
|
||||
if (items.length === 0) continue;
|
||||
value = `[${items.join(', ')}]`;
|
||||
}
|
||||
switch (key) {
|
||||
case 'title':
|
||||
front.title = value;
|
||||
@@ -305,6 +313,69 @@ export async function writeNote(root: string, input: NoteInput): Promise<{ note:
|
||||
return { note: await readNoteAt(root, target), appended: false };
|
||||
}
|
||||
|
||||
/** Raised when a note changed under an editor that was holding it open. */
|
||||
export class NoteConflict extends Error {
|
||||
readonly path: string;
|
||||
readonly modifiedAt: string;
|
||||
|
||||
constructor(path: string, modifiedAt: string) {
|
||||
super(`The note "${path}" was changed by something else since it was loaded.`);
|
||||
this.name = 'NoteConflict';
|
||||
this.path = path;
|
||||
this.modifiedAt = modifiedAt;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a note's contents — what an editor does when it saves.
|
||||
*
|
||||
* Separate from `writeNote`, which never overwrites: that rule protects
|
||||
* `add_note` from clobbering a note it did not mean to touch, and it is exactly
|
||||
* wrong for a page whose whole job is editing the day in front of you.
|
||||
*
|
||||
* `expectedModifiedAt` is how the two stay compatible. The notes are a folder
|
||||
* that may be synced and is certainly open in more than one place — a phone in
|
||||
* the lesson, a laptop after it — so a save that would overwrite a version the
|
||||
* editor never saw is refused rather than silently winning.
|
||||
*/
|
||||
export async function replaceNote(
|
||||
root: string,
|
||||
relative: string,
|
||||
input: { title: string; text: string; date?: string; subject?: string; courseId?: string; tags?: string[]; source?: string },
|
||||
options: { expectedModifiedAt?: string } = {},
|
||||
): Promise<NoteDoc> {
|
||||
const path = normalizeRelative(relative);
|
||||
const absolute = resolveWithin(root, path);
|
||||
|
||||
const info = await stat(absolute).catch(() => undefined);
|
||||
if (info && options.expectedModifiedAt) {
|
||||
// Second resolution: some filesystems and most sync tools do not preserve
|
||||
// milliseconds, so comparing the full ISO string would report a conflict
|
||||
// for a file nobody touched.
|
||||
const seen = Math.floor(new Date(options.expectedModifiedAt).getTime() / 1000);
|
||||
const actual = Math.floor(info.mtime.getTime() / 1000);
|
||||
if (Number.isFinite(seen) && actual > seen) throw new NoteConflict(path, info.mtime.toISOString());
|
||||
}
|
||||
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
await writeFile(
|
||||
absolute,
|
||||
renderNote(
|
||||
{
|
||||
title: input.title,
|
||||
...(input.date ? { date: input.date } : {}),
|
||||
...pick('subject', input.subject),
|
||||
...pick('courseId', input.courseId),
|
||||
...pick('source', input.source),
|
||||
...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
|
||||
},
|
||||
input.text,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
return readNoteAt(root, path);
|
||||
}
|
||||
|
||||
/** A note as it is stored: frontmatter, then the body. */
|
||||
export function renderNote(front: NoteFrontmatter, body: string): string {
|
||||
const lines = [
|
||||
@@ -362,6 +433,86 @@ async function freePath(root: string, relative: string): Promise<string> {
|
||||
throw new Error(`Too many notes named like ${relative}.`);
|
||||
}
|
||||
|
||||
// --- sections: one note per school day, one heading per lesson -------------
|
||||
|
||||
/**
|
||||
* A `##` section of a note, which for a day note is one lesson.
|
||||
*
|
||||
* The shape the notes page writes — one note per school day, titled with the
|
||||
* date, a heading per timetable lesson, prose and lists and tables beneath —
|
||||
* is the shape people actually take notes in, and it is the reason this exists.
|
||||
* Indexing such a note whole would make every hit read "my note, Monday" and
|
||||
* lose the one thing that makes it findable: which lesson it was.
|
||||
*/
|
||||
export interface NoteSection {
|
||||
/** The heading text, without its `##`. */
|
||||
heading: string;
|
||||
/** The subject read out of the heading, when there is one. */
|
||||
subject?: string;
|
||||
/** The body under the heading, subheadings included. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `##` sections of a note, in order. Empty when it has none — a subject
|
||||
* note from the import is one piece of prose, and has to stay that way.
|
||||
*
|
||||
* Fenced code blocks are skipped, or a `## comment` inside one would split the
|
||||
* note where nobody wrote a heading.
|
||||
*/
|
||||
export function noteSections(note: NoteDoc): NoteSection[] {
|
||||
const sections: NoteSection[] = [];
|
||||
let current: { heading: string; lines: string[] } | undefined;
|
||||
let fence: string | undefined;
|
||||
|
||||
for (const line of note.text.split('\n')) {
|
||||
const fenceMark = /^\s{0,3}(```+|~~~+)/.exec(line);
|
||||
if (fenceMark) {
|
||||
if (!fence) fence = fenceMark[1]![0];
|
||||
else if (fenceMark[1]!.startsWith(fence)) fence = undefined;
|
||||
}
|
||||
const heading = fence ? null : /^##\s+(.*\S)\s*$/.exec(line);
|
||||
if (heading) {
|
||||
if (current) sections.push(toSection(current));
|
||||
current = { heading: heading[1]!, lines: [] };
|
||||
continue;
|
||||
}
|
||||
if (current) current.lines.push(line);
|
||||
}
|
||||
if (current) sections.push(toSection(current));
|
||||
return sections;
|
||||
}
|
||||
|
||||
function toSection(raw: { heading: string; lines: string[] }): NoteSection {
|
||||
const subject = subjectFromHeading(raw.heading);
|
||||
return { heading: raw.heading, ...(subject ? { subject } : {}), text: raw.lines.join('\n').trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject a lesson heading names.
|
||||
*
|
||||
* Lenient on purpose: the page writes `1. Deutsch — 08:00–08:45 · Meier`, but
|
||||
* a heading typed by hand is just `Deutsch`, and both have to work. Leading
|
||||
* period numbers and clock times are stripped, then the subject is whatever
|
||||
* comes before the first separator.
|
||||
*/
|
||||
export function subjectFromHeading(heading: string): string | undefined {
|
||||
let value = heading.trim();
|
||||
value = value.replace(/^\d{1,2}\s*[.)]\s*/, '');
|
||||
value = value.replace(/^(\d{1,2}:\d{2}\s*[–—-]\s*\d{1,2}:\d{2}|\d{1,2}:\d{2})\s*[–—·|-]?\s*/, '');
|
||||
value = value.split(/\s[–—·|]\s|\s{2,}|\(/)[0]!.trim();
|
||||
value = value.replace(/[:,;]+$/, '').trim();
|
||||
// A heading that is only a time, a number or punctuation names no subject,
|
||||
// and guessing one would file the lesson under nonsense.
|
||||
if (!value || !/[\p{L}]/u.test(value) || value.length > 60) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Where a school day's note lives: `2026/2026-09-15.md`. */
|
||||
export function dayNotePath(date: string): string {
|
||||
return `${date.slice(0, 4)}/${date}.md`;
|
||||
}
|
||||
|
||||
// --- matching ------------------------------------------------------------
|
||||
|
||||
/** Everything about a note that search should look at, as one string. */
|
||||
@@ -369,6 +520,19 @@ export function noteSearchText(note: NoteDoc): string {
|
||||
return [note.title, note.subject, note.tags.join(' '), note.text].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every subject a note covers: its own, plus one per lesson heading.
|
||||
*
|
||||
* A day note has no subject of its own and covers five or six, so asking only
|
||||
* the frontmatter would make "my Deutsch notes" return nothing at all for
|
||||
* anyone who writes a note per day.
|
||||
*/
|
||||
export function noteSubjects(note: NoteDoc): string[] {
|
||||
const subjects = note.subject ? [note.subject] : [];
|
||||
for (const section of noteSections(note)) if (section.subject) subjects.push(section.subject);
|
||||
return [...new Set(subjects)];
|
||||
}
|
||||
|
||||
/** Filters a list the way `list_notes` does. Pure, and shared with the CLI. */
|
||||
export function filterNotes(
|
||||
notes: NoteDoc[],
|
||||
@@ -376,7 +540,7 @@ export function filterNotes(
|
||||
): NoteDoc[] {
|
||||
const subject = filter.subject?.trim().toLowerCase();
|
||||
return notes.filter((note) => {
|
||||
if (subject && !(note.subject ?? '').toLowerCase().includes(subject)) return false;
|
||||
if (subject && !noteSubjects(note).some((name) => name.toLowerCase().includes(subject))) return false;
|
||||
if (filter.courseId && note.courseId !== filter.courseId) return false;
|
||||
// A note with no date cannot be excluded by a date window without
|
||||
// silently hiding it; undated notes always pass.
|
||||
@@ -432,10 +596,17 @@ function dateFromFileName(fileName: string): string | undefined {
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : undefined;
|
||||
}
|
||||
|
||||
/** The first folder is the subject, by the layout `notePathFor` writes. */
|
||||
/**
|
||||
* The first folder is the subject, by the layout `notePathFor` writes.
|
||||
*
|
||||
* Except a year: notes filed under `2026/` are filed by date, and calling the
|
||||
* year a subject would put every lesson of a school year under one.
|
||||
*/
|
||||
function subjectFromPath(relative: string): string | undefined {
|
||||
const parts = relative.split('/');
|
||||
return parts.length > 1 ? parts[0] : undefined;
|
||||
if (parts.length < 2) return undefined;
|
||||
const first = parts[0]!;
|
||||
return /^\d{4}$/.test(first) ? undefined : first;
|
||||
}
|
||||
|
||||
/** `15.09.2026` and `2026-09-15T08:00:00Z` both mean the same school day. */
|
||||
@@ -446,6 +617,23 @@ function normalizeDate(value: string): string | undefined {
|
||||
return iso ? iso[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `- item` lines directly under a `key:` with no inline value.
|
||||
*
|
||||
* Stops at the first line that is not one, so a list never swallows the key
|
||||
* after it.
|
||||
*/
|
||||
function blockList(lines: string[], from: number): string[] {
|
||||
const items: string[] = [];
|
||||
for (let i = from + 1; i < lines.length; i++) {
|
||||
const item = /^[ \t]+-\s+(.*)$/.exec(lines[i]!);
|
||||
if (!item) break;
|
||||
const value = unquote(item[1]!.trim());
|
||||
if (value) items.push(value);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseList(value: string): string[] {
|
||||
const inner = /^\[(.*)\]$/.exec(value)?.[1] ?? value;
|
||||
return inner
|
||||
|
||||
101
src/http/api.ts
101
src/http/api.ts
@@ -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
135
src/http/app-page.ts
Normal 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
188
src/http/app/app.css
Normal 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
490
src/http/app/app.js
Normal 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
6
src/http/app/icon.svg
Normal 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
87
src/http/app/index.html
Normal 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>
|
||||
14
src/http/app/manifest.webmanifest
Normal file
14
src/http/app/manifest.webmanifest
Normal 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" }
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
197
src/http/web-auth.ts
Normal 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.' });
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { germanDay, isCalendarDate, schoolToday } from '../../core/dates.ts';
|
||||
import {
|
||||
filterNotes,
|
||||
NoteNotFound,
|
||||
noteSections,
|
||||
noteSubjects,
|
||||
normalizeRelative,
|
||||
readNoteAt,
|
||||
readNotes,
|
||||
writeNote,
|
||||
@@ -52,8 +55,9 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo
|
||||
'nor in WebUntis and is often the only record of what a teacher actually said. **Read these before ' +
|
||||
'answering anything about what was covered in class, and before preparing for a test**: they say what ' +
|
||||
'was emphasised, which the uploaded material does not. Filter by subject ("Deutsch", "LF07") or by ' +
|
||||
'date to get the lessons around a topic. Returns titles and first lines; get_note opens one. ' +
|
||||
'search finds notes by their contents as well.',
|
||||
'date to get the lessons around a topic — a note is often a whole school day with a heading per ' +
|
||||
'lesson, so the subject filter looks at those headings too. Returns titles and a preview; get_note ' +
|
||||
'opens one. search finds notes by their contents as well, and names the lesson it matched in.',
|
||||
inputSchema: {
|
||||
subject: z
|
||||
.string()
|
||||
@@ -78,7 +82,9 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo
|
||||
|
||||
const terms = query ? tokenize(query) : [];
|
||||
const matched = filterNotes(all, { subject, since, until }).filter(
|
||||
(note) => terms.length === 0 || matchesAll([note.title, note.subject, note.tags.join(' ')].join(' '), terms),
|
||||
(note) =>
|
||||
terms.length === 0 ||
|
||||
matchesAll([note.title, ...noteSubjects(note), note.tags.join(' ')].join(' '), terms),
|
||||
);
|
||||
if (matched.length === 0) {
|
||||
return text(
|
||||
@@ -115,16 +121,20 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async ({ path }) => {
|
||||
// A search hit inside a day's note carries `<path>#2`; opening the note
|
||||
// is the right answer, so the anchor is dropped rather than 404ing on a
|
||||
// filename nobody has.
|
||||
const wanted = normalizeRelative(path).replace(/#\d+$/, '');
|
||||
try {
|
||||
return text(renderNote(await readNoteAt(root, path)));
|
||||
return text(renderNote(await readNoteAt(root, wanted)));
|
||||
} catch (error) {
|
||||
if (error instanceof NoteNotFound) {
|
||||
return failure(
|
||||
`There is no note at "${path}". Paths come from list_notes or search and include the folder and ` +
|
||||
`There is no note at "${wanted}". Paths come from list_notes or search and include the folder and ` +
|
||||
'the .md ending.',
|
||||
);
|
||||
}
|
||||
return toToolError(error, `read the note "${path}"`);
|
||||
return toToolError(error, `read the note "${wanted}"`);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -195,18 +205,23 @@ export function registerNoteTools(server: McpServer, context: ServerContext): vo
|
||||
|
||||
function listLine(note: NoteDoc): string {
|
||||
const when = note.date ? germanDay(note.date) : 'ohne Datum';
|
||||
const where = note.subject ? ` · ${note.subject}` : '';
|
||||
const subjects = noteSubjects(note);
|
||||
const where = subjects.length > 0 ? ` · ${subjects.join(', ')}` : '';
|
||||
const tags = note.tags.length > 0 ? ` · ${note.tags.map((tag) => `#${tag}`).join(' ')}` : '';
|
||||
const first = firstLine(note.text);
|
||||
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, first ? ` ${first}` : undefined]
|
||||
// For a day's note the lessons are the useful preview; for a single piece of
|
||||
// prose the first line is.
|
||||
const sections = noteSections(note);
|
||||
const preview = sections.length > 0 ? `${sections.length} Stunde(n)` : firstLine(note.text);
|
||||
return [`- **${note.title}** — ${when}${where}${tags} \`${note.path}\``, preview ? ` ${preview}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function renderNote(note: NoteDoc): string {
|
||||
const subjects = noteSubjects(note);
|
||||
const facts = [
|
||||
note.date ? `Datum: ${germanDay(note.date)}` : undefined,
|
||||
note.subject ? `Fach: ${note.subject}` : undefined,
|
||||
subjects.length > 0 ? `${subjects.length > 1 ? 'Fächer' : 'Fach'}: ${subjects.join(', ')}` : undefined,
|
||||
note.tags.length > 0 ? `Tags: ${note.tags.join(', ')}` : undefined,
|
||||
note.courseId ? `Kurs: \`${note.courseId}\`` : undefined,
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -166,8 +166,13 @@ function nextStep(hit: SearchResult): string {
|
||||
? ` → \`fs_read\` with path \`${fsPath}\``
|
||||
: ` → \`fs_read\` with fileId \`${hit.nodeId}\` and name \`${hit.title}\``;
|
||||
}
|
||||
// A note is addressed by its path, not by an id.
|
||||
if (hit.kind === 'note') return ` → \`get_note\` with path \`${hit.nodeId}\``;
|
||||
// A note is addressed by its path, not by an id — and a lesson inside a day's
|
||||
// note is reached by opening the note, since `#3` is an index into this
|
||||
// generation and means nothing to get_note.
|
||||
if (hit.kind === 'note') {
|
||||
const notePath = typeof hit.meta?.notePath === 'string' ? hit.meta.notePath : hit.nodeId.replace(/#\d+$/, '');
|
||||
return ` → \`get_note\` with path \`${notePath}\``;
|
||||
}
|
||||
if (hit.kind === 'untis') {
|
||||
const periodId = hit.meta?.periodId;
|
||||
return ` → \`untis_lesson_topics\` with periodId \`${typeof periodId === 'number' ? periodId : hit.nodeId}\``;
|
||||
@@ -192,9 +197,10 @@ function placeOf(hit: SearchResult): string {
|
||||
if (hit.kind === 'note') {
|
||||
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
||||
const subject = typeof hit.meta?.subject === 'string' ? hit.meta.subject : undefined;
|
||||
const heading = typeof hit.meta?.heading === 'string' ? hit.meta.heading : undefined;
|
||||
// Named as the user's own writing, so it is never quoted as if the school
|
||||
// had published it.
|
||||
return ['my own note', subject, date].filter(Boolean).join(', ');
|
||||
// had published it. A lesson within a day's note says which lesson.
|
||||
return ['my own note', subject ?? heading, date].filter(Boolean).join(', ');
|
||||
}
|
||||
if (hit.kind === 'untis') {
|
||||
const date = typeof hit.meta?.date === 'string' ? formatDate(hit.meta.date) : undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { CrawledFile, Snapshot } from '../core/crawl.ts';
|
||||
import { noteSearchText } from '../core/notes.ts';
|
||||
import { noteSearchText, noteSections } from '../core/notes.ts';
|
||||
import { mirrorPath } from '../core/paths.ts';
|
||||
import { lessonLogText } from '../core/untis-history.ts';
|
||||
import { connect, migrate, type Db } from './db.ts';
|
||||
@@ -654,21 +654,50 @@ export function snapshotToNodes(snapshot: Snapshot): StoredNode[] {
|
||||
// most notes sit outside any course — which is also what makes them survive
|
||||
// a per-course crawl's carry-forward untouched.
|
||||
for (const note of snapshot.notes ?? []) {
|
||||
nodes.push({
|
||||
kind: 'note',
|
||||
nodeId: note.path,
|
||||
const common = {
|
||||
courseId: note.courseId ?? null,
|
||||
title: note.title,
|
||||
body: noteSearchText(note),
|
||||
path: `Notizen/${note.path}`,
|
||||
meta: {
|
||||
notePath: note.path,
|
||||
...(note.date ? { date: note.date } : {}),
|
||||
...(note.subject ? { subject: note.subject } : {}),
|
||||
...(note.source ? { source: note.source } : {}),
|
||||
tags: note.tags,
|
||||
modifiedAt: note.modifiedAt,
|
||||
bytes: note.bytes,
|
||||
},
|
||||
};
|
||||
|
||||
// A note written as one school day, with a heading per lesson, is indexed
|
||||
// per lesson: one node for "Deutsch, 15.09." rather than one for "Monday".
|
||||
// Indexed whole, every hit in it would read "my note, Monday" and lose the
|
||||
// only thing that makes it findable — and "what did we do in Deutsch"
|
||||
// would match a note whose other five lessons were something else.
|
||||
const sections = noteSections(note);
|
||||
if (sections.length > 0) {
|
||||
for (const [index, section] of sections.entries()) {
|
||||
nodes.push({
|
||||
...common,
|
||||
kind: 'note',
|
||||
// The heading's position, not its text: renaming a heading should
|
||||
// read as an edit, and two lessons of the same subject on one day
|
||||
// must not collide.
|
||||
nodeId: `${note.path}#${index}`,
|
||||
title: section.subject ? `${section.subject} — ${note.date ?? note.title}` : section.heading,
|
||||
body: [section.heading, section.text].filter(Boolean).join('\n'),
|
||||
path: `Notizen/${note.path} → ${section.heading}`,
|
||||
meta: { ...common.meta, heading: section.heading, ...(section.subject ? { subject: section.subject } : {}) },
|
||||
digest: digestOf([section.heading, section.text]),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
...common,
|
||||
kind: 'note',
|
||||
nodeId: note.path,
|
||||
title: note.title,
|
||||
body: noteSearchText(note),
|
||||
path: `Notizen/${note.path}`,
|
||||
meta: { ...common.meta, ...(note.subject ? { subject: note.subject } : {}), bytes: note.bytes },
|
||||
// The file's mtime is deliberately not in the digest: a sync tool that
|
||||
// rewrites a file byte-for-byte must not show up as a changed note.
|
||||
digest: digestOf([note.title, note.text, note.subject ?? '', note.date ?? '', note.tags]),
|
||||
|
||||
Reference in New Issue
Block a user