Aktueller Stand
This commit is contained in:
10
lib/auth.ts
10
lib/auth.ts
@@ -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
91
lib/ical-export.ts
Normal 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
57
lib/rate-limit.ts
Normal 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
5
lib/request.ts
Normal 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";
|
||||
};
|
||||
Reference in New Issue
Block a user