Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
101
lib/api/context.ts
Normal file
101
lib/api/context.ts
Normal 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
86
lib/api/responses.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
69
lib/auth/hub-provisioning.ts
Normal file
69
lib/auth/hub-provisioning.ts
Normal 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
79
lib/auth/hub.ts
Normal 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
21
lib/auth/password.ts
Normal 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
101
lib/auth/session.ts
Normal 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
60
lib/config.ts
Normal 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
23
lib/db/client.ts
Normal 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
43
lib/db/tx.ts
Normal 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
45
lib/invites/tokens.ts
Normal 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;
|
||||
}
|
||||
141
lib/permissions/permissions.ts
Normal file
141
lib/permissions/permissions.ts
Normal 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
40
lib/privacy/display.ts
Normal 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);
|
||||
}
|
||||
81
lib/reservations/conflicts.ts
Normal file
81
lib/reservations/conflicts.ts
Normal 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
238
lib/reservations/service.ts
Normal 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
109
lib/security/rate-limit.ts
Normal 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
159
lib/validation.ts
Normal 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();
|
||||
Reference in New Issue
Block a user