Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

101
lib/api/context.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Hilfsfunktionen fuer API-Routen: laedt den Benutzer samt relevanten
* Beziehungen aus der Datenbank und stellt den Kontext fuer die
* Berechtigungspruefungen bereit.
*
* Wichtig (plan.md Abschnitt 2): Berechtigungen werden IMMER
* serverseitig geprueft, nie nur im Frontend versteckt.
*/
import { prisma } from '@/lib/db/client';
import { getAuthenticatedUser } from '@/lib/auth/session';
import { UnauthorizedError } from '@/lib/auth/session';
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
export interface CalendarContext {
user: AuthenticatedUser;
calendar: {
id: string;
ownerId: string;
isActive: boolean;
title: string;
description: string | null;
timezone: string;
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
maxReservationMinutes: number;
/** Duerven Nicht-Admins die Titel fremder Reservierungen sehen? */
showTitleToMembers: boolean;
/** Duerven Nicht-Admins die Notizen fremder Reservierungen sehen? */
showNotesToMembers: boolean;
};
/** Ist der angemeldete User Mitglied dieses Kalenders? */
isMember: boolean;
}
/** Laedt den vollstaendigen DB-Benutzer zur aktuellen Session. */
export async function requireDbUser() {
const authUser = await getAuthenticatedUser();
const user = await prisma.user.findUnique({
where: { id: authUser.id },
select: { id: true, email: true, username: true, role: true },
});
if (!user) {
// Session gueltig, aber Benutzer geloescht -> Session ist ungueltig.
throw new UnauthorizedError();
}
return user;
}
/**
* Laedt einen Kalender samt Mitgliedschaft des Users.
* Wirft UnauthorizedError, wenn der User den Kalender ueberhaupt
* nicht sehen darf - so werden Daten anderer Kalender niemals ueber
* die API preisgegeben.
*
* Zugriff hat (konsistent mit canViewCalendar in der zentralen
* Berechtigungsschicht):
* - ADMIN: alle Kalender (Verwaltungsrolle, auch die anderer User)
* - USER: nur der eine Kalender, in dem er Mitglied ist
*/
export async function requireCalendarContext(
calendarId: string,
): Promise<CalendarContext> {
const user = await requireDbUser();
const calendar = await prisma.calendar.findUnique({
where: { id: calendarId },
select: {
id: true,
ownerId: true,
isActive: true,
title: true,
description: true,
timezone: true,
maxReservationMinutes: true,
showTitleToMembers: true,
showNotesToMembers: true,
},
});
if (!calendar) {
throw new UnauthorizedError();
}
const isOwner = calendar.ownerId === user.id;
const membership = await prisma.calendarMember.findUnique({
where: { userId: user.id },
select: { calendarId: true },
});
const isMember = membership?.calendarId === calendar.id;
// Admins duerfen alle Kalender sehen und verwalten (canViewCalendar /
// canManageCalendar). User nur ihren eigenen (Mitgliedschaft).
const canAccess =
user.role === 'ADMIN' || isOwner || isMember;
if (!canAccess) {
// Bewusst 401 statt 404: Es wird nicht verraten, dass der
// Kalender existiert (plan.md Abschnitt 2).
throw new UnauthorizedError();
}
return { user, calendar, isMember };
}

86
lib/api/responses.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Einheitliche API-Antwort-Helfer (plan.md: "HTTP-Statuscodes korrekt
* verwenden", "saubere Fehlerbehandlung").
*/
import { NextResponse } from 'next/server';
import { ZodError } from 'zod';
import { UnauthorizedError } from '@/lib/auth/session';
import {
ReservationConflictError,
ReservationValidationError,
} from '@/lib/reservations/conflicts';
import {
MaxDurationExceededError,
CalendarInactiveError,
ReservationNotFoundError,
} from '@/lib/reservations/service';
/** 400: Validierungsfehler mit lesbaren Meldungen. */
export function validationErrorResponse(error: ZodError): NextResponse {
const messages = error.issues.map((issue) => issue.message).join(' ');
return NextResponse.json({ error: messages }, { status: 400 });
}
/** 401: Nicht angemeldet. */
export function unauthorizedResponse(): NextResponse {
return NextResponse.json({ error: 'Nicht angemeldet.' }, { status: 401 });
}
/** 403: Angemeldet, aber keine Berechtigung. */
export function forbiddenResponse(): NextResponse {
return NextResponse.json(
{ error: 'Keine Berechtigung fuer diese Aktion.' },
{ status: 403 },
);
}
/** 404: Ressource nicht gefunden (oder bewusst verschleiert). */
export function notFoundResponse(message = 'Nicht gefunden.'): NextResponse {
return NextResponse.json({ error: message }, { status: 404 });
}
/** 409: Zeitkollision. */
export function conflictResponse(): NextResponse {
return NextResponse.json(
{ error: 'Der gewuenschte Zeitraum ist bereits belegt.' },
{ status: 409 },
);
}
/**
* Mappt bekannte Fehler auf korrekte HTTP-Statuscodes.
* Unbekannte Fehler werden geloggt und als 500 gemeldet, ohne
* interne Details preiszugeben.
*/
export function errorResponse(error: unknown): NextResponse {
if (error instanceof ZodError) {
return validationErrorResponse(error);
}
if (error instanceof UnauthorizedError) {
return unauthorizedResponse();
}
if (error instanceof ReservationConflictError) {
return conflictResponse();
}
// Validierungsfehler (Vergangenheit, Start>=Ende): 400 mit Meldung.
if (error instanceof ReservationValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
// Dauer ueberschritten: 400 mit lesbare Meldung (nicht 500).
if (error instanceof MaxDurationExceededError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
// Kalender deaktiviert/nicht gefunden: 403 (Berechtigung/Status).
if (error instanceof CalendarInactiveError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
// Reservierung nicht gefunden: 404.
if (error instanceof ReservationNotFoundError) {
return notFoundResponse(error.message);
}
console.error('[api] Unerwarteter Fehler:', error);
return NextResponse.json(
{ error: 'Ein interner Fehler ist aufgetreten.' },
{ status: 500 },
);
}

View File

@@ -0,0 +1,69 @@
/**
* JIT-Provisioning (platform-plan.md §3.2):
* Koppelt einen Hub-User an einen lokalen Kalendartool-Account.
*
* - Existiert bereits ein Account mit dieser hubId → zurückgeben.
* - Existiert ein Account mit gleicher E-Mail (lokaler Login) → koppeln
* (hubId setzen), Passwort/Rolle bleiben unangetastet.
* - Sonst → neuen Account anlegen (Default-Rolle USER, kein Passwort).
*
* Bewusst KEINE Rollen-Synchronisation: Der Kalendertool-Admin verwaltet
* Rollen weiterhin selbst (Hub entscheidet nur über Tool-Zugriff).
*/
import { prisma } from "@/lib/db/client";
import type { AuthenticatedUser } from "@/lib/permissions/permissions";
import type { Role } from "@prisma/client";
export interface HubIdentity {
hubId: string;
email: string;
name: string;
isAdmin: boolean;
}
export async function provisionHubUser(identity: HubIdentity): Promise<AuthenticatedUser> {
// 1. Bereits gekoppelt?
const byHubId = await prisma.user.findUnique({ where: { hubId: identity.hubId } });
if (byHubId) {
return {
id: byHubId.id,
email: byHubId.email,
username: byHubId.username,
role: byHubId.role,
};
}
// 2. Gleiche E-Mail vorhanden (lokaler Account) → koppeln.
const byEmail = await prisma.user.findUnique({ where: { email: identity.email } });
if (byEmail) {
const linked = await prisma.user.update({
where: { id: byEmail.id },
data: { hubId: identity.hubId },
});
return {
id: linked.id,
email: linked.email,
username: linked.username,
role: linked.role,
};
}
// 3. Neuen Account anlegen (JIT). Kein Passwort – Login nur via Hub.
const created = await prisma.user.create({
data: {
email: identity.email,
username: identity.name || null,
// passwordHash ist NOT NULL → nicht ratbares Zufallspasswort.
passwordHash: crypto.randomUUID() + crypto.randomUUID(),
role: "USER" as Role,
hubId: identity.hubId,
},
});
return {
id: created.id,
email: created.email,
username: created.username,
role: created.role,
};
}

79
lib/auth/hub.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* JWKS-Client: lädt die öffentlichen Hub-Schlüssel und cached sie.
* Tools validieren Hub-Tokens gegen diesen Key (RS256) – kein geteiltes Secret.
*
* platform-plan.md §5.2: Der Hub exposet /.well-known/jwks.json.
*/
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { getConfig } from "@/lib/config";
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 Minuten (passend zum Hub-Cache-Header)
interface CachedJwks {
jwks: ReturnType<typeof createRemoteJWKSet>;
fetchedAt: number;
}
let cache: CachedJwks | null = null;
function getHubUrl(): string {
const hubUrl = getConfig().hubUrl;
if (!hubUrl) {
throw new Error("HUB_URL fehlt – Hub-SSO ist nicht konfiguriert.");
}
return hubUrl.replace(/\/$/, "");
}
/** Remote-JWKSet mit einfachem TTL-Cache (jose hat keinen eingebauten). */
function getJwksSet(): ReturnType<typeof createRemoteJWKSet> {
const now = Date.now();
if (cache && now - cache.fetchedAt < JWKS_CACHE_TTL_MS) {
return cache.jwks;
}
const jwks = createRemoteJWKSet(new URL(`${getHubUrl()}/.well-known/jwks.json`));
cache = { jwks, fetchedAt: now };
return jwks;
}
export interface HubTokenResult {
hubId: string;
email: string;
name: string;
isAdmin: boolean;
}
/**
* Validiert ein Hub-Token (Signatur, TTL, aud, iss).
* Wirft bei jedem Fehler – der Aufrufer entscheidet über die Response.
*/
export async function verifyHubToken(token: string): Promise<HubTokenResult> {
const config = getConfig();
const expectedAud = config.hubToolSlug;
if (!expectedAud) {
throw new Error("HUB_TOOL_SLUG fehlt – Tool-Slug für Hub-SSO nicht konfiguriert.");
}
const { payload } = await jwtVerify(token, getJwksSet(), {
algorithms: ["RS256"],
audience: expectedAud,
issuer: config.hubIssuer ?? "http://localhost:3001",
clockTolerance: 5,
});
const hubId = payload.sub;
const email = payload.email;
const name = payload.name;
if (typeof hubId !== "string" || typeof email !== "string" || typeof name !== "string") {
throw new Error("Hub-Token enthält unvollständige Claims.");
}
return {
hubId,
email,
name,
isAdmin: payload.isAdmin === true,
};
}
/** Nur für Typ-Export (jose JWTPayload) – nicht Teil des Contracts. */
export type { JWTPayload };

21
lib/auth/password.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Passwort-Hashing mit bcrypt (plan.md Abschnitt 11:
* "Passwoerter niemals selbst verschluesseln oder im Klartext speichern").
*/
import bcrypt from 'bcryptjs';
/** Kostenfaktor: 12 Runden sind aktueller Standard (ca. 200-300 ms). */
const BCRYPT_ROUNDS = 12;
/** Hasht ein Klartext-Passwort fuer die Speicherung in der Datenbank. */
export async function hashPassword(plainPassword: string): Promise<string> {
return bcrypt.hash(plainPassword, BCRYPT_ROUNDS);
}
/** Prueft ein Klartext-Passwort gegen den gespeicherten Hash. */
export async function verifyPassword(
plainPassword: string,
passwordHash: string,
): Promise<boolean> {
return bcrypt.compare(plainPassword, passwordHash);
}

101
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Session-Management (plan.md Abschnitt 11).
*
* Signierte, HttpOnly-Cookies auf Basis von JWT (jose). Keine eigene
* Kryptografie, keine Klartext-Sessions in der Datenbank.
*/
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
import { getConfig } from '@/lib/config';
import type { AuthenticatedUser } from '@/lib/permissions/permissions';
const SESSION_COOKIE_NAME = 'calendar_session';
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 Tage
/** Fehler bei ungueltiger/abgelaufener Session. */
export class UnauthorizedError extends Error {
constructor() {
super('Nicht angemeldet oder Sitzung abgelaufen.');
this.name = 'UnauthorizedError';
}
}
async function getSessionKey(): Promise<Uint8Array> {
const secret = getConfig().sessionSecret;
return new TextEncoder().encode(secret);
}
/** Erstellt ein signiertes Session-Token fuer den Benutzer. */
export async function createSessionToken(user: {
id: string;
email: string;
role: string;
}): Promise<string> {
const key = await getSessionKey();
return new SignJWT({ sub: user.id, email: user.email, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
.sign(key);
}
/** Setzt das Session-Cookie sicher (HttpOnly, SameSite=Lax, Secure in Prod). */
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: SESSION_MAX_AGE_SECONDS,
path: '/',
});
}
/** Loescht das Session-Cookie (Logout). */
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
/**
* Liest und verifiziert die aktuelle Session.
* Wirft UnauthorizedError, wenn keine gueltige Session existiert.
*/
export async function getAuthenticatedUser(): Promise<AuthenticatedUser> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!token) {
throw new UnauthorizedError();
}
try {
const { payload } = await jwtVerify(token, await getSessionKey());
if (typeof payload.sub !== 'string' || typeof payload.role !== 'string') {
throw new UnauthorizedError();
}
// Alte Tokens (vor E-Mail-Erweiterung) enthalten keine E-Mail;
// in dem Fall wird ein Platzhalter verwendet, requireDbUser laedt
// ohnehin die frischen Daten aus der DB.
const email =
typeof payload.email === 'string' ? payload.email : 'unbekannt@lokal';
// Username ist nicht im JWT (kann sich aendern); requireDbUser
// laedt den aktuellen Wert aus der DB. Das JWT liefert null als
// Platzhalter, damit der Typ erfuellt ist.
return {
id: payload.sub,
email,
username: null,
role: payload.role as AuthenticatedUser['role'],
};
} catch {
throw new UnauthorizedError();
}
}
/** Wie getAuthenticatedUser, gibt aber null statt zu werfen. */
export async function tryGetAuthenticatedUser(): Promise<AuthenticatedUser | null> {
try {
return await getAuthenticatedUser();
} catch {
return null;
}
}

60
lib/config.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Zentrale, typsichere Konfiguration aus Umgebungsvariablen.
*
* Alle Werte werden beim ersten Zugriff validiert, damit Fehlkonfigurationen
* sofort (und nicht erst zur Laufzeit an versteckter Stelle) auffallen.
* Keine Magic Strings in der restlichen Anwendung.
*/
function requireEnv(name: string): string {
const value = process.env[name];
if (!value || value.trim().length === 0) {
throw new Error(
`Umgebungsvariable ${name} ist nicht gesetzt. Bitte .env anlegen (Vorlage: .env.example).`,
);
}
return value.trim();
}
/** Session-Secret: mindestens 32 Zeichen, sonst ist es nicht sicher. */
function requireSessionSecret(): string {
const secret = requireEnv('SESSION_SECRET');
if (secret.length < 32) {
throw new Error(
'SESSION_SECRET muss mindestens 32 Zeichen lang sein (z. B. crypto.randomBytes(48).toString("base64url")).',
);
}
return secret;
}
/** Liest die Konfiguration einmalig und cached das Ergebnis. */
let cachedConfig: AppConfig | undefined;
export interface AppConfig {
sessionSecret: string;
appUrl: string;
/** Wenn false, ist die Registrierung nur ueber Invite-Links moeglich. */
allowOpenRegistration: boolean;
/** MultiToolApp-Plattform: Basis-URL (JWKS-Abruf). null = SSO deaktiviert. */
hubUrl: string | null;
/** Tool-Slug in der Hub-Registry (Token-`aud`). */
hubToolSlug: string | null;
/** Erwarteter Token-`iss` (Hub-Basis-URL). */
hubIssuer: string | null;
}
export function getConfig(): AppConfig {
if (cachedConfig) {
return cachedConfig;
}
cachedConfig = {
sessionSecret: requireSessionSecret(),
appUrl: process.env.APP_URL?.trim() || 'http://localhost:3000',
allowOpenRegistration:
(process.env.ALLOW_OPEN_REGISTRATION ?? 'true').toLowerCase() === 'true',
hubUrl: process.env.HUB_URL?.trim() || null,
hubToolSlug: process.env.HUB_TOOL_SLUG?.trim() || null,
hubIssuer: process.env.HUB_ISSUER?.trim() || null,
};
return cachedConfig;
}

23
lib/db/client.ts Normal file
View File

@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client';
/**
* Globaler Prisma-Client.
*
* In der Entwicklung startet Next.js bei Hot Reload mehrfach Modul-Instanzen;
* ohne Caching wuerde jede Instanz eigene Verbindungen aufbauen und die
* Datenbank mit Verbindungen ueberfluten. Daher wird die Instanz auf
* `globalThis` gecacht (offizielles Prisma-Rezept fuer Next.js).
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma: PrismaClient =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

43
lib/db/tx.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Transaktions-Helper: SERIALIZABLE mit Retry bei Serialisierungs-
* konflikten (Prisma-Fehlercode P2034).
*
* Postgres bricht bei Parallelitaets-Anomalien unter SERIALIZABLE eine
* der beteiligten Transaktionen ab. Wiederholbare Operationen (z. B.
* Registrierung mit Invite-Verbrauch, Rollen-Aenderung) werden dann
* einfach erneut ausgefuehrt - der Konflikt loest sich auf, weil die
* andere Transaktion inzwischen committet hat.
*/
import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/db/client';
/** Ist es ein wiederholbarer Serialisierungs-Konflikt? */
function isSerializationError(error: unknown): boolean {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2034'
);
}
/**
* Fuehrt die Operation in einer SERIALIZABLE-Transaktion aus und
* wiederholt sie bei Serialisierungs-Konflikten (max. 3 Nachlaeufe).
*/
export async function withSerializableTx<T>(
operation: (tx: Prisma.TransactionClient) => Promise<T>,
maxRetries = 3,
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await prisma.$transaction(operation, {
isolationLevel: 'Serializable',
});
} catch (error) {
if (attempt >= maxRetries || !isSerializationError(error)) {
throw error;
}
// Naechster Versuch: Die abgebrochene Transaktion hat nichts
// committet, ein erneuter Lauf sieht den aktuellen Stand.
}
}
}

45
lib/invites/tokens.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Invite-System (plan.md Abschnitt 3 und 9).
*
* - Token wird zufaellig und nicht erratbar erzeugt (crypto.randomBytes).
* - In der Datenbank liegt NUR der SHA-256-Hash des Tokens.
* - Der Klartext-Token wird genau einmal beim Erstellen zurueckgegeben.
*/
import { createHash, randomBytes } from 'node:crypto';
/** Laenge des rohen Tokens in Bytes (256 Bit Entropie -> nicht erratbar). */
const TOKEN_BYTE_LENGTH = 32;
/** Invite-Links laufen standardmaessig nach 7 Tagen ab. */
export const INVITE_DEFAULT_TTL_DAYS = 7;
/** Erzeugt einen neuen, nicht erratbaren Invite-Token (Klartext). */
export function generateInviteToken(): string {
return randomBytes(TOKEN_BYTE_LENGTH).toString('base64url');
}
/** Berechnet den SHA-256-Hash eines Tokens fuer die Datenbank. */
export function hashInviteToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
/** Standard-Ablaufzeit fuer neue Invites. */
export function defaultInviteExpiry(now: Date = new Date()): Date {
const expiry = new Date(now);
expiry.setDate(expiry.getDate() + INVITE_DEFAULT_TTL_DAYS);
return expiry;
}
/** Ist ein Invite gueltig (nicht abgelaufen, nicht verwendet)? */
export function isInviteUsable(
invite: { expiresAt: Date | null; usedAt: Date | null },
now: Date = new Date(),
): boolean {
if (invite.usedAt !== null) {
return false;
}
if (invite.expiresAt !== null && invite.expiresAt <= now) {
return false;
}
return true;
}

View File

@@ -0,0 +1,141 @@
/**
* Zentrale Definition aller Berechtigungen.
*
* Aus plan.md Abschnitt 20: Eine zentrale Berechtigungsschicht, damit
* Berechtigungslogik nicht ueber den ganzen Code verteilt wird.
* Alle API-Routen und Server-Komponenten MUSSSEN diese Funktionen
* verwenden statt eigene Ad-hoc-Pruefungen zu schreiben.
*/
import type { Role } from '@prisma/client';
/** Minimal notwendige Informationen ueber den angemeldeten Benutzer. */
export interface AuthenticatedUser {
id: string;
email: string;
/** Frei waehlbarer Anzeigename; null, wenn nicht gesetzt. */
username: string | null;
role: Role;
}
/** Minimal notwendige Informationen ueber einen Kalender. */
export interface CalendarRef {
id: string;
ownerId: string;
isActive: boolean;
}
/** Minimal notwendige Informationen ueber eine Reservierung. */
export interface ReservationRef {
id: string;
calendarId: string;
userId: string;
}
/** Minimal notwendige Informationen ueber eine Mitgliedschaft. */
export interface MembershipRef {
calendarId: string;
userId: string;
}
/**
* Darf der User den Kalender sehen?
* Admin: alle Kalender (Verwaltungsrolle). User: nur Kalender, in
* denen er Mitglied ist. Deaktivierte Kalender bleiben fuer Member
* sichtbar (mit Hinweis), damit sie ihre Reservierungen noch einsehen
* koennen.
*/
export function canViewCalendar(
user: AuthenticatedUser,
calendar: CalendarRef,
isMember: boolean,
): boolean {
if (user.role === 'ADMIN') {
return true;
}
return isMember;
}
/**
* Darf der User den Kalender verwalten (bearbeiten, Invites erstellen,
* loeschen)? Admins duerfen alle Kalender verwalten (auch die anderer
* Benutzer), normale User keinen.
*/
export function canManageCalendar(
user: AuthenticatedUser,
calendar: CalendarRef,
): boolean {
return user.role === 'ADMIN';
}
/**
* Darf der User eine Reservierung in diesem Kalender erstellen?
* Admins in allen aktiven Kalendern, User nur in ihrem einen Kalender
* (Mitgliedschaft). Deaktivierte Kalender erlauben keine neuen
* Reservierungen.
*/
export function canCreateReservation(
user: AuthenticatedUser,
calendar: CalendarRef,
isMember: boolean,
): boolean {
if (!calendar.isActive) {
return false;
}
if (user.role === 'ADMIN') {
return true;
}
return isMember;
}
/**
* Darf der User eine Reservierung LOESCHEN?
* Ausschliesslich Admins (in allen Kalendern). Normale User koennen
* Reservierungen nur anlegen und sehen - nicht loeschen.
*/
export function canDeleteReservation(
user: AuthenticatedUser,
_reservation: ReservationRef,
calendar: CalendarRef,
): boolean {
return user.role === 'ADMIN' && canViewCalendar(user, calendar, false);
}
/**
* Darf der User die Reservierung sehen?
* Admin: in allen Kalendern alle. User: nur eigene Reservierungen
* im eigenen Kalender.
*/
export function canViewReservation(
user: AuthenticatedUser,
reservation: ReservationRef,
calendar: CalendarRef,
isMember: boolean,
): boolean {
if (!canViewCalendar(user, calendar, isMember)) {
return false;
}
if (user.role === 'ADMIN') {
return true;
}
return reservation.userId === user.id;
}
/**
* Darf der User die Reservierung aendern oder loeschen?
* Admin: alle Reservierungen in allen Kalendern.
* User: ausschliesslich eigene Reservierungen.
*/
export function canModifyReservation(
user: AuthenticatedUser,
reservation: ReservationRef,
calendar: CalendarRef,
isMember: boolean,
): boolean {
if (!canViewCalendar(user, calendar, isMember)) {
return false;
}
if (user.role === 'ADMIN') {
return true;
}
return reservation.userId === user.id;
}

40
lib/privacy/display.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Datenschutz-Helfer fuer die Anzeige von Benutzerbezügen.
*
* E-Mail-Adressen sind personenbezogene Daten. In Kalender- und
* Reservierungslisten sehen normale Mitglieder nur einen maskierten
* Kuerzel statt der vollstaendigen Adresse; wer einen Username gesetzt
* hat, erscheint unter diesem. Vollstaendige E-Mails sind der
* Admin-Verwaltung vorbehalten.
*/
/**
* Maskiert eine E-Mail: "max.mustermann@example.com" -> "m***@e***.com".
* Fuer ungueltige/leere Eingaben wird ein neutraler Platzhalter
* zurueckgegeben.
*/
export function maskEmail(email: string | null | undefined): string {
if (!email) {
return 'Unbekannt';
}
const at = email.indexOf('@');
if (at <= 0 || at === email.length - 1) {
return 'Unbekannt';
}
const local = email.slice(0, 1);
const domain = email.slice(at + 1);
const lastDot = domain.lastIndexOf('.');
const domainMasked =
lastDot > 0
? `${domain.slice(0, 1)}***${domain.slice(lastDot)}`
: `${domain.slice(0, 1)}***`;
return `${local}***@${domainMasked}`;
}
/** Anzeigename: Username bevorzugt, sonst maskierte E-Mail. */
export function displayName(
username: string | null | undefined,
email: string | null | undefined,
): string {
return username ?? maskEmail(email);
}

View File

@@ -0,0 +1,81 @@
/**
* Konfliktpruefung fuer Reservierungen (plan.md Abschnitt 6).
*
* Zwei Zeitraume kollidieren, wenn sie sich ueberlappen:
* existing.start < new.end UND new.start < existing.end
*
* Die Pruefung MUSS serverseitig innerhalb einer Transaktion mit
* konsistentem Isolation-Level erfolgen, damit gleichzeitige Buchungen
* zweier Benutzer nicht zu einer Doppelbuchung fuehren koennen.
*/
import type { Reservation } from '@prisma/client';
/** Fehler, der bei einer Zeitkollision geworfen wird. */
export class ReservationConflictError extends Error {
constructor() {
super('Der gewuenschte Zeitraum ist bereits belegt.');
this.name = 'ReservationConflictError';
}
}
/** Fehler, wenn der Zeitraum ungueltig ist (Vergangenheit, Start>=Ende). */
export class ReservationValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ReservationValidationError';
}
}
/** Prueft rein funktional, ob sich zwei Zeitraume ueberlappen. */
export function intervalsOverlap(
aStart: Date,
aEnd: Date,
bStart: Date,
bEnd: Date,
): boolean {
return aStart < bEnd && bStart < aEnd;
}
/**
* Findet kollidierende Reservierungen eines Kalenders im Zeitraum.
* Rein funktional und damit gut testbar; die Datenbankabfrage wird
* als Parameter injiziert.
*/
export function findConflicts(
existing: Pick<Reservation, 'id' | 'startAt' | 'endAt'>[],
newStart: Date,
newEnd: Date,
ignoreReservationId?: string,
): Pick<Reservation, 'id' | 'startAt' | 'endAt'>[] {
return existing.filter(
(reservation) =>
reservation.id !== ignoreReservationId &&
intervalsOverlap(reservation.startAt, reservation.endAt, newStart, newEnd),
);
}
/**
* Validiert, dass Start vor Ende liegt und der Zeitraum nicht in der
* Vergangenheit beginnt. Wirft sonst eine aussagekraeftige Fehlermeldung.
*/
export function validateReservationPeriod(
startAt: Date,
endAt: Date,
now: Date = new Date(),
): void {
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new ReservationValidationError(
'Start- und Endzeit muessen gueltige Zeitpunkte sein.',
);
}
if (startAt >= endAt) {
throw new ReservationValidationError(
'Die Startzeit muss vor der Endzeit liegen.',
);
}
if (startAt < now) {
throw new ReservationValidationError(
'Der Zeitraum liegt in der Vergangenheit.',
);
}
}

238
lib/reservations/service.ts Normal file
View File

@@ -0,0 +1,238 @@
/**
* Service-Schicht fuer Reservierungen (plan.md Abschnitt 5, 6 und 15).
*
* Die Doppelbuchungspruefung laeuft ATOMAR innerhalb einer serialisierbaren
* Transaktion: Der Server validiert Berechtigung, Zeitraum, maximale
* Dauer und Kollision in einem einzigen kritischen Abschnitt. Zwei
* gleichzeitige Buchungen koennen so niemals denselben Slot belegen.
*
* Dauer: Reservierungen geben Start- und Endzeit an; die maximale
* Dauer wird gegen die Kalender-Einstellung (max_reservation_minutes)
* geprueft. 0 bedeutet unbegrenzt.
*/
import { prisma } from '@/lib/db/client';
import { ReservationConflictError, validateReservationPeriod } from '@/lib/reservations/conflicts';
import type { Reservation } from '@prisma/client';
/** Zeitfenster-Puffer rund um den gebuchten Zeitraum (24 Stunden). */
const WINDOW_BUFFER_MS = 24 * 60 * 60 * 1000;
/**
* Absolute Obergrenze fuer eine einzelne Reservierung (24 Stunden).
* Gilt AUCH wenn der Kalender "unbegrenzt" (0) eingestellt ist, damit
* kein Benutzer den Kalender mit jahrelangen Blockaden lahmlegen kann.
*/
export const ABSOLUTE_MAX_DURATION_MINUTES = 24 * 60;
/** Fehler, wenn die maximale Reservierungsdauer ueberschritten wird. */
export class MaxDurationExceededError extends Error {
readonly maxMinutes: number;
constructor(maxMinutes: number) {
super(
maxMinutes === 0
? 'Die Reservierungsdauer ist ungueltig.'
: `Maximale Reservierungsdauer ist ${formatDuration(maxMinutes)}.`,
);
this.name = 'MaxDurationExceededError';
this.maxMinutes = maxMinutes;
}
}
/** Fehler, wenn der Kalender nicht existiert oder deaktiviert ist. */
export class CalendarInactiveError extends Error {
constructor() {
super('Kalender nicht gefunden oder deaktiviert.');
this.name = 'CalendarInactiveError';
}
}
/** Fehler, wenn die zu aendernde Reservierung nicht existiert. */
export class ReservationNotFoundError extends Error {
constructor() {
super('Reservierung nicht gefunden.');
this.name = 'ReservationNotFoundError';
}
}
/** Formatiert Minuten als lesbare Angabe (z. B. "4 Stunden" / "90 Minuten"). */
export function formatDuration(minutes: number): string {
if (minutes < 60) {
return `${minutes} Minuten`;
}
const hours = minutes / 60;
return Number.isInteger(hours)
? `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
: `${hours.toFixed(1).replace('.', ',')} Stunden`;
}
/** Prueft, ob der Zeitraum die maximale Dauer des Kalenders einhaelt. */
function assertMaxDuration(
startAt: Date,
endAt: Date,
maxReservationMinutes: number,
): void {
const durationMinutes = (endAt.getTime() - startAt.getTime()) / 60_000;
// Absolute Obergrenze gilt immer (DoS-Schutz gegen Blockaden).
if (durationMinutes > ABSOLUTE_MAX_DURATION_MINUTES) {
throw new MaxDurationExceededError(ABSOLUTE_MAX_DURATION_MINUTES);
}
if (maxReservationMinutes === 0) {
return; // 0 = unbegrenzt (innerhalb der absoluten Obergrenze).
}
if (durationMinutes > maxReservationMinutes) {
throw new MaxDurationExceededError(maxReservationMinutes);
}
}
export interface CreateReservationInput {
calendarId: string;
userId: string;
title: string;
startAt: Date;
endAt: Date;
notes?: string | null;
}
export interface UpdateReservationInput {
startAt?: Date;
endAt?: Date;
title?: string;
notes?: string | null;
status?: 'CONFIRMED' | 'CANCELLED';
}
/**
* Erstellt eine Reservierung atomar.
*
* Ablauf innerhalb der Transaktion:
* 1. Kalender laden und pruefen (aktiv? maximale Dauer?).
* 2. Bestehende Reservierungen im Zeitfenster sperren/lesen.
* 3. Kollision pruefen -> bei Konflikt ReservationConflictError.
* 4. Reservierung anlegen.
*
* Das Isolation-Level SERIALIZABLE verhindert, dass zwei parallele
* Transaktionen dieselbe Luecke gleichzeitig als frei einstufen.
*/
export async function createReservationAtomically(
input: CreateReservationInput,
): Promise<Reservation> {
validateReservationPeriod(input.startAt, input.endAt);
return prisma.$transaction(
async (tx) => {
const calendar = await tx.calendar.findUnique({
where: { id: input.calendarId },
select: { id: true, isActive: true, maxReservationMinutes: true },
});
if (!calendar || !calendar.isActive) {
throw new CalendarInactiveError();
}
assertMaxDuration(input.startAt, input.endAt, calendar.maxReservationMinutes);
// Zeitfenster mit Puffer lesen: alle Reservierungen, die den Tag
// des neuen Zeitraums ueberdecken, genau genug fuer den Overlap-Check.
const windowStart = new Date(input.startAt.getTime() - WINDOW_BUFFER_MS);
const windowEnd = new Date(input.endAt.getTime() + WINDOW_BUFFER_MS);
const overlapping = await tx.reservation.findMany({
where: {
calendarId: input.calendarId,
status: 'CONFIRMED',
startAt: { lt: windowEnd },
endAt: { gt: windowStart },
},
select: { id: true, startAt: true, endAt: true },
});
const conflict = overlapping.some(
(existing) => existing.startAt < input.endAt && input.startAt < existing.endAt,
);
if (conflict) {
throw new ReservationConflictError();
}
return tx.reservation.create({
data: {
calendarId: input.calendarId,
userId: input.userId,
title: input.title,
startAt: input.startAt,
endAt: input.endAt,
notes: input.notes ?? null,
},
});
},
{ isolationLevel: 'Serializable' },
);
}
/**
* Aktualisiert eine Reservierung atomar (gleiche Konflikt- und
* Dauerlogik; die eigene Reservierung wird aus der Kollisionspruefung
* ausgenommen).
*/
export async function updateReservationAtomically(
reservationId: string,
input: UpdateReservationInput,
): Promise<Reservation> {
return prisma.$transaction(
async (tx) => {
const existing = await tx.reservation.findUnique({
where: { id: reservationId },
});
if (!existing) {
throw new ReservationNotFoundError();
}
const startAt = input.startAt ?? existing.startAt;
const endAt = input.endAt ?? existing.endAt;
validateReservationPeriod(startAt, endAt);
const calendar = await tx.calendar.findUnique({
where: { id: existing.calendarId },
select: { isActive: true, maxReservationMinutes: true },
});
// Konsistenz zum Anlegen: In deaktivierten Kalendern sind auch
// Aenderungen bestehender Reservierungen nicht mehr moeglich.
if (!calendar || !calendar.isActive) {
throw new CalendarInactiveError();
}
assertMaxDuration(startAt, endAt, calendar.maxReservationMinutes);
const windowStart = new Date(startAt.getTime() - WINDOW_BUFFER_MS);
const windowEnd = new Date(endAt.getTime() + WINDOW_BUFFER_MS);
const overlapping = await tx.reservation.findMany({
where: {
calendarId: existing.calendarId,
status: 'CONFIRMED',
id: { not: reservationId },
startAt: { lt: windowEnd },
endAt: { gt: windowStart },
},
select: { id: true, startAt: true, endAt: true },
});
const conflict = overlapping.some(
(other) => other.startAt < endAt && startAt < other.endAt,
);
if (conflict) {
throw new ReservationConflictError();
}
return tx.reservation.update({
where: { id: reservationId },
data: {
startAt,
endAt,
title: input.title ?? existing.title,
notes: input.notes === undefined ? existing.notes : input.notes,
status: input.status ?? existing.status,
},
});
},
{ isolationLevel: 'Serializable' },
);
}

109
lib/security/rate-limit.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* In-Memory Rate-Limiting (plan.md Abschnitt 11).
*
* Fester Zeitfenster-Algorithmus pro Schluessel (z. B. "login:email:x@y").
* Bewusst In-Memory: Fuer den MVP ausreichend; bei mehreren App-Instanzen
* muesste ein gemeinsamer Store (z. B. Redis) verwendet werden.
*
* Die Anzahl getrackter Schluessel ist begrenzt (Schutz vor Memory-DoS
* durch Angreifer mit vielen erfundenen E-Mails/IPs). Beim Erreichen
* des Limits werden zuerst abgelaufene, danach die aeltesten Eintraege
* entfernt.
*/
/** Maximale Anzahl gleichzeitig getrackter Schluessel. */
const MAX_TRACKED_KEYS = 10_000;
interface Bucket {
count: number;
firstAt: number;
}
const buckets = new Map<string, Bucket>();
export interface RateLimitResult {
allowed: boolean;
/** Sekunden bis zum naechsten erlaubten Versuch (falls blockiert). */
retryAfterSeconds: number;
}
/** Raeumt abgelaufene bzw. ueberschuessige Eintraege auf. */
function makeRoom(now: number, windowMs: number): void {
if (buckets.size < MAX_TRACKED_KEYS) {
return;
}
// 1) Abgelaufene Eintraege entfernen (Map-Iteration erlaubt Loeschen).
for (const [key, bucket] of buckets) {
if (now - bucket.firstAt > windowMs) {
buckets.delete(key);
}
}
// 2) Immer noch voll: aelteste Eintraege verwerfen (Map behaelt die
// Einfuege-Reihenfolge bei).
while (buckets.size >= MAX_TRACKED_KEYS) {
const oldest = buckets.keys().next();
if (oldest.done) {
break;
}
buckets.delete(oldest.value);
}
}
/** Prueft, ob der Schluessel aktuell gesperrt ist (ohne zu zaehlen). */
export function checkRateLimit(
key: string,
max: number,
windowMs: number,
): RateLimitResult {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now - bucket.firstAt > windowMs) {
return { allowed: true, retryAfterSeconds: 0 };
}
if (bucket.count >= max) {
return {
allowed: false,
retryAfterSeconds: Math.max(
1,
Math.ceil((bucket.firstAt + windowMs - now) / 1000),
),
};
}
return { allowed: true, retryAfterSeconds: 0 };
}
/** Zaehlt einen Versuch innerhalb des Zeitfensters. */
export function recordRateLimitHit(key: string, windowMs: number): void {
const now = Date.now();
const bucket = buckets.get(key);
if (!bucket || now - bucket.firstAt > windowMs) {
makeRoom(now, windowMs);
buckets.set(key, { count: 1, firstAt: now });
return;
}
bucket.count += 1;
}
/** Entfernt den Schluessel (z. B. nach erfolgreichem Login). */
export function resetRateLimit(key: string): void {
buckets.delete(key);
}
/**
* Client-IP aus den Standard-Proxy-Headern.
*
* Hinweis: Ohne vertrauenswuerdigen Proxy ist x-forwarded-for
* clientseitig spoofbar. Limits auf Basis echter Identitaeten
* (z. B. E-Mail beim Login) sind davon nicht betroffen; IP-Limits
* sind eine zusaetzliche, nicht die einzige Schranke.
*/
export function getClientIp(request: {
headers: { get(name: string): string | null };
}): string {
const forwarded = request.headers.get('x-forwarded-for');
const first = forwarded?.split(',')[0]?.trim();
if (first) {
return first;
}
return request.headers.get('x-real-ip')?.trim() || 'unbekannt';
}

159
lib/validation.ts Normal file
View File

@@ -0,0 +1,159 @@
/**
* Eingabevalidierung mit Zod (plan.md: "Jede Eingabe gilt als unsicher").
* Alle API-Routen validieren Request-Bodies ausschliesslich ueber diese Schemas.
*/
import { z } from 'zod';
/** Sichere E-Mail (Zod prueft Format, wir begrenzen die Laenge). */
export const emailSchema = z
.string()
.trim()
.toLowerCase()
.min(3, 'E-Mail ist erforderlich.')
.max(254, 'E-Mail ist zu lang.')
.email('Ungueltige E-Mail-Adresse.');
/**
* Passwortregeln: mindestens 10 Zeichen, Gross-/Kleinbuchstabe und Ziffer.
* Maximale Laenge begrenzen (DoS-Schutz bei bcrypt).
*/
export const passwordSchema = z
.string()
.min(10, 'Das Passwort muss mindestens 10 Zeichen lang sein.')
.max(72, 'Das Passwort darf hoechstens 72 Zeichen lang sein.')
.regex(/[a-z]/, 'Das Passwort muss einen Kleinbuchstaben enthalten.')
.regex(/[A-Z]/, 'Das Passwort muss einen Grossbuchstaben enthalten.')
.regex(/[0-9]/, 'Das Passwort muss eine Ziffer enthalten.');
/**
* Username: 2-32 Zeichen, Buchstaben, Ziffern, Unterstrich, Bindestrich,
* Punkt. Keine Leerzeichen oder Sonderzeichen (verhindert Spoofing und
* Injection in der Anzeige). Case-insensitive Eindeutigkeit wird in der
* DB sichergestellt; hier wird nur das Format geprueft.
*/
export const usernameSchema = z
.string()
.trim()
.min(2, 'Der Benutzername muss mindestens 2 Zeichen lang sein.')
.max(32, 'Der Benutzername darf hoechstens 32 Zeichen lang sein.')
.regex(
/^[a-zA-Z0-9_.-]+$/,
'Der Benutzername darf nur Buchstaben, Ziffern, Unterstrich, Bindestrich und Punkt enthalten.',
);
export const registerSchema = z
.object({
email: emailSchema,
password: passwordSchema,
username: usernameSchema.optional(),
inviteToken: z.string().trim().min(10).max(200).optional(),
})
.strict();
export const loginSchema = z
.object({
email: emailSchema,
password: z.string().min(1, 'Passwort ist erforderlich.').max(72),
})
.strict();
export const createCalendarSchema = z
.object({
title: z.string().trim().min(1, 'Titel ist erforderlich.').max(120),
description: z.string().trim().max(2000).optional().nullable(),
timezone: z.string().trim().min(1).max(64),
})
.strict();
export const updateCalendarSchema = createCalendarSchema
.partial()
.extend({
isActive: z.boolean().optional(),
/** Maximale Reservierungsdauer in Minuten; 0 = unbegrenzt. */
maxReservationMinutes: z
.number()
.int('Die maximale Dauer muss ein Vielfaches von 15 Minuten sein.')
.min(0, 'Die maximale Dauer kann nicht negativ sein.')
.max(72 * 60, 'Die maximale Dauer betraegt hoechstens 72 Stunden.')
.multipleOf(15, 'Die maximale Dauer muss ein Vielfaches von 15 Minuten sein.')
.optional(),
/** Duerven Nicht-Admins die Titel fremder Reservierungen sehen? */
showTitleToMembers: z.boolean().optional(),
/** Duerven Nicht-Admins die Notizen fremder Reservierungen sehen? */
showNotesToMembers: z.boolean().optional(),
})
.strict();
/** Dauer in Minuten: 15 Minuten bis 24 Stunden, in 15-Minuten-Schritten. */
export const durationSchema = z
.number()
.int('Die Dauer muss ein Vielfaches von 15 Minuten sein.')
.min(15, 'Die Mindestdauer betraegt 15 Minuten.')
.max(24 * 60, 'Die Maximaldauer betraegt 24 Stunden.')
.multipleOf(15, 'Die Dauer muss ein Vielfaches von 15 Minuten sein.');
/**
* Reservierung anlegen: Start- UND Endzeit werden angegeben. Die
* Dauer ergibt sich aus der Differenz; die maximale Dauer wird
* serverseitig gegen die Kalender-Einstellung geprueft.
*/
export const createReservationSchema = z
.object({
calendarId: z.string().trim().min(1),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
title: z.string().trim().min(1, 'Titel ist erforderlich.').max(120),
notes: z.string().trim().max(2000).optional().nullable(),
})
.strict();
export const updateReservationSchema = z
.object({
startAt: z.coerce.date().optional(),
endAt: z.coerce.date().optional(),
title: z.string().trim().min(1).max(120).optional(),
notes: z.string().trim().max(2000).optional().nullable(),
status: z.enum(['CONFIRMED', 'CANCELLED']).optional(),
})
.strict();
export const createInviteSchema = z
.object({
calendarId: z.string().trim().min(1),
expiresInDays: z.number().int().min(1).max(90).optional(),
})
.strict();
/**
* Mitglied per E-Mail zu einem Kalender hinzufuegen (DB-basiertes
* Einladungssystem, ersetzt den Token-Link-Flow in der Verwaltung).
*/
export const addMemberSchema = z
.object({
email: emailSchema,
})
.strict();
/** Rollenaenderung (Userverwaltung): nur die beiden bekannten Rollen. */
export const updateRoleSchema = z
.object({
role: z.enum(['ADMIN', 'USER'], {
errorMap: () => ({ message: 'Rolle muss ADMIN oder USER sein.' }),
}),
})
.strict();
/** Berechnet die Endzeit aus Startzeit und Dauer in Minuten. */
export function computeEndAt(startAt: Date, durationMinutes: number): Date {
return new Date(startAt.getTime() + durationMinutes * 60_000);
}
/**
* Username im Profil aendern. Der Wert ist optional (null = loeschen),
* aber wenn er gesetzt ist, muss er dem Format entsprechen.
*/
export const updateUsernameSchema = z
.object({
username: usernameSchema.nullable(),
})
.strict();