Aktueller Stand

This commit is contained in:
2026-01-15 23:18:42 +01:00
parent 46eae2a2a9
commit dcf45bac3d
32 changed files with 2625 additions and 395 deletions

View File

@@ -3,6 +3,7 @@ import bcrypt from "bcryptjs";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "./prisma";
import { checkRateLimit, getRateLimitConfig } from "./rate-limit";
const MAX_LOGIN_ATTEMPTS = 5;
const LOGIN_WINDOW_MINUTES = 15;
@@ -76,6 +77,15 @@ export const authOptions: NextAuthOptions = {
const email = normalizeEmail(credentials.email);
const ip = getClientIp(req);
const rateConfig = getRateLimitConfig("RATE_LIMIT_LOGIN", 10);
const rate = await checkRateLimit({
key: `login:${email}:${ip}`,
limit: rateConfig.limit,
windowMs: rateConfig.windowMs
});
if (!rate.ok) {
throw new Error("RATE_LIMIT");
}
let attempt: { id: string; attempts: number; lastAttempt: Date; lockedUntil: Date | null } | null = null;
try {
attempt = await prisma.loginAttempt.findUnique({

91
lib/ical-export.ts Normal file
View File

@@ -0,0 +1,91 @@
import ical from "ical-generator";
import { NextResponse } from "next/server";
import { prisma } from "./prisma";
const DEFAULT_APP_NAME = "Vereinskalender";
const toFilename = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "kalender";
export async function getIcalResponse(request: Request, token: string) {
const view = await prisma.userView.findUnique({
where: { token },
include: {
items: { include: { event: true } },
categories: true,
exclusions: true,
user: true
}
});
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const appNameSetting = await prisma.setting.findUnique({
where: { key: "app_name" }
});
const appName = appNameSetting?.value || DEFAULT_APP_NAME;
const url = new URL(request.url);
const pastDaysParam = Number(url.searchParams.get("pastDays"));
const rawPastDays =
Number.isFinite(pastDaysParam) && pastDaysParam >= 0
? pastDaysParam
: Number.isFinite(view.icalPastDays) && view.icalPastDays >= 0
? view.icalPastDays
: 14;
const pastDays = Math.min(365, Math.floor(rawPastDays));
const cutoff = new Date(Date.now() - pastDays * 24 * 60 * 60 * 1000);
const calendar = ical({
name: appName,
timezone: "Europe/Berlin"
});
const excludedIds = new Set(view.exclusions.map((item) => item.eventId));
const explicitEvents = view.items
.map((item) => item.event)
.filter((event) => event.status === "APPROVED");
const categoryIds = view.categories.map((item) => item.categoryId);
const categoryEvents =
categoryIds.length > 0
? await prisma.event.findMany({
where: { categoryId: { in: categoryIds }, status: "APPROVED" }
})
: [];
const combined = [...explicitEvents, ...categoryEvents].filter(
(event, index, all) =>
all.findIndex((item) => item.id === event.id) === index &&
!excludedIds.has(event.id) &&
event.startAt >= cutoff
);
combined.forEach((event) => {
const start = event.startAt;
const end =
event.endAt || new Date(event.startAt.getTime() + 3 * 60 * 60 * 1000);
calendar.createEvent({
id: event.id,
summary: event.title,
description: event.description || undefined,
location: event.location || undefined,
start,
end
});
});
const filename = `${toFilename(appName)}.ical`;
return new NextResponse(calendar.toString(), {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`
}
});
}

57
lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,57 @@
import { prisma } from "./prisma";
type RateLimitResult = {
ok: boolean;
remaining: number;
resetAt: Date;
};
const parseNumber = (value: string | undefined, fallback: number) => {
if (!value) return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
export const getRateLimitConfig = (envKey: string, defaultLimit: number) => {
const limit = parseNumber(process.env[envKey], defaultLimit);
const windowMinutes = parseNumber(process.env.RATE_LIMIT_WINDOW_MINUTES, 15);
return { limit, windowMs: windowMinutes * 60 * 1000 };
};
export async function checkRateLimit({
key,
limit,
windowMs
}: {
key: string;
limit: number;
windowMs: number;
}): Promise<RateLimitResult> {
const now = new Date();
const resetAt = new Date(now.getTime() + windowMs);
const existing = await prisma.rateLimit.findUnique({ where: { key } });
if (!existing || existing.resetAt <= now) {
await prisma.rateLimit.upsert({
where: { key },
update: { count: 1, resetAt },
create: { key, count: 1, resetAt }
});
return { ok: true, remaining: Math.max(0, limit - 1), resetAt };
}
if (existing.count >= limit) {
return { ok: false, remaining: 0, resetAt: existing.resetAt };
}
const updated = await prisma.rateLimit.update({
where: { key },
data: { count: { increment: 1 } }
});
return {
ok: true,
remaining: Math.max(0, limit - updated.count),
resetAt: updated.resetAt
};
}

5
lib/request.ts Normal file
View File

@@ -0,0 +1,5 @@
export const getClientIp = (req: Request) => {
const forwarded = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip");
if (!forwarded) return "unknown";
return forwarded.split(",")[0].trim() || "unknown";
};