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

@@ -26,7 +26,9 @@ export async function GET(request: Request) {
const events = await prisma.event.findMany({
where,
orderBy: { startAt: "asc" },
include: { category: true }
include: isAdmin
? { category: true, createdBy: { select: { name: true, email: true } } }
: { category: true }
});
return NextResponse.json(events);

View File

@@ -0,0 +1,8 @@
import { getIcalResponse } from "../../../../../lib/ical-export";
export async function GET(
request: Request,
context: { params: { token: string; filename: string } }
) {
return getIcalResponse(request, context.params.token);
}

View File

@@ -1,66 +1,8 @@
import ical from "ical-generator";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { getIcalResponse } from "../../../../lib/ical-export";
export async function GET(
_request: Request,
request: Request,
context: { params: { token: string } }
) {
const view = await prisma.userView.findUnique({
where: { token: context.params.token },
include: {
items: { include: { event: true } },
categories: true,
exclusions: true,
user: true
}
});
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const calendar = ical({
name: `Vereinskalender - ${view.name}`,
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)
);
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
});
});
return new NextResponse(calendar.toString(), {
headers: {
"Content-Type": "text/calendar; charset=utf-8"
}
});
return getIcalResponse(request, context.params.token);
}

View File

@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import { parseICS } from "node-ical";
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
import { prisma } from "../../../../lib/prisma";
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
import { getClientIp } from "../../../../lib/request";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
@@ -11,6 +13,32 @@ const asText = (value: unknown) => {
return String(value).trim();
};
const parseGeo = (value: unknown) => {
if (!value) return null;
if (typeof value === "string") {
const cleaned = value.trim();
if (!cleaned) return null;
const parts = cleaned.split(/[;,]/).map((part) => part.trim());
if (parts.length >= 2) {
const lat = Number(parts[0]);
const lng = Number(parts[1]);
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
return { lat, lng };
}
}
return null;
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
const lat = Number(record.lat ?? record.latitude);
const lng = Number(record.lon ?? record.lng ?? record.longitude);
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
return { lat, lng };
}
}
return null;
};
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
@@ -20,6 +48,22 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Nur für Admins." }, { status: 403 });
}
const ip = getClientIp(request);
const email = session.user?.email || "unknown";
const rateKey = `icalimport:${email}:${ip}`;
const rateConfig = getRateLimitConfig("RATE_LIMIT_ICAL_IMPORT", 5);
const rate = await checkRateLimit({
key: rateKey,
limit: rateConfig.limit,
windowMs: rateConfig.windowMs
});
if (!rate.ok) {
return NextResponse.json(
{ error: "Zu viele Importe. Bitte später erneut versuchen." },
{ status: 429 }
);
}
const formData = await request.formData();
const file = formData.get("file");
const categoryId = asText(formData.get("categoryId"));
@@ -103,6 +147,7 @@ export async function POST(request: Request) {
: new Date(start.getTime() + 3 * 60 * 60 * 1000);
const location = asText(entry.location) || null;
const description = asText(entry.description) || null;
const geo = parseGeo(entry.geo);
const existing = await prisma.event.findFirst({
where: {
@@ -123,6 +168,8 @@ export async function POST(request: Request) {
title,
description,
location,
locationLat: geo ? geo.lat : null,
locationLng: geo ? geo.lng : null,
startAt: start,
endAt: end,
status: "APPROVED",

View File

@@ -2,6 +2,8 @@ import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { sendMail } from "../../../../lib/mailer";
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
import { getClientIp } from "../../../../lib/request";
export async function POST(request: Request) {
const body = await request.json();
@@ -11,9 +13,37 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
const normalizedEmail = String(email).trim().toLowerCase();
const ip = getClientIp(request);
const rateKey = `pwreset:${normalizedEmail}:${ip}`;
const rateConfig = getRateLimitConfig("RATE_LIMIT_PASSWORD_RESET", 3);
const rate = await checkRateLimit({
key: rateKey,
limit: rateConfig.limit,
windowMs: rateConfig.windowMs
});
if (!rate.ok) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte später erneut versuchen." },
{ status: 429 }
);
}
const user = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (user) {
const existingToken = await prisma.passwordResetToken.findFirst({
where: {
userId: user.id,
createdAt: { gt: new Date(Date.now() - 15 * 60 * 1000) }
},
orderBy: { createdAt: "desc" }
});
if (existingToken) {
return NextResponse.json({ ok: true });
}
await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } });
const token = randomUUID();
@@ -31,7 +61,7 @@ export async function POST(request: Request) {
const resetUrl = `${baseUrl}/reset/confirm?token=${token}`;
await sendMail({
to: email,
to: normalizedEmail,
subject: "Passwort zurücksetzen",
text: `Passwort zurücksetzen: ${resetUrl}`
});

View File

@@ -4,6 +4,8 @@ import { randomUUID } from "crypto";
import { prisma } from "../../../lib/prisma";
import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
import { sendMail } from "../../../lib/mailer";
import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit";
import { getClientIp } from "../../../lib/request";
export async function POST(request: Request) {
const registrationSetting = await prisma.setting.findUnique({
@@ -24,6 +26,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 });
}
const ip = getClientIp(request);
const rateKey = `register:${normalizedEmail}:${ip}`;
const rateConfig = getRateLimitConfig("RATE_LIMIT_REGISTER", 5);
const rate = await checkRateLimit({
key: rateKey,
limit: rateConfig.limit,
windowMs: rateConfig.windowMs
});
if (!rate.ok) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte später erneut versuchen." },
{ status: 429 }
);
}
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (existing) {
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
const DEFAULT_APP_NAME = "Vereinskalender";
export async function GET() {
const setting = await prisma.setting.findUnique({
where: { key: "app_name" }
});
return NextResponse.json({ name: setting?.value || DEFAULT_APP_NAME });
}
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isSuperAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const name = String(body?.name || "").trim();
if (!name) {
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
}
if (name.length > 60) {
return NextResponse.json({ error: "Name ist zu lang." }, { status: 400 });
}
await prisma.setting.upsert({
where: { key: "app_name" },
update: { value: name },
create: { key: "app_name", value: name }
});
return NextResponse.json({ name });
}

View File

@@ -16,6 +16,7 @@ const MIME_TO_EXT: Record<string, string> = {
"image/webp": "webp",
"image/svg+xml": "svg"
};
const MAX_FILE_SIZE = 2 * 1024 * 1024;
const resolveLogoPath = (relativePath: string) => {
const absolutePath = path.join(DATA_DIR, relativePath);
@@ -51,6 +52,9 @@ export async function POST(request: Request) {
if (!extension) {
return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "Datei ist zu groß (max. 2 MB)." }, { status: 400 });
}
await fs.mkdir(UPLOADS_DIR, { recursive: true });

View File

@@ -2,6 +2,8 @@ import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { sendMail } from "../../../../lib/mailer";
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
import { getClientIp } from "../../../../lib/request";
export async function POST(request: Request) {
const body = await request.json();
@@ -11,7 +13,23 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
const normalizedEmail = String(email).trim().toLowerCase();
const ip = getClientIp(request);
const rateKey = `verify:${normalizedEmail}:${ip}`;
const rateConfig = getRateLimitConfig("RATE_LIMIT_VERIFY_EMAIL", 3);
const rate = await checkRateLimit({
key: rateKey,
limit: rateConfig.limit,
windowMs: rateConfig.windowMs
});
if (!rate.ok) {
return NextResponse.json(
{ error: "Zu viele Anfragen. Bitte später erneut versuchen." },
{ status: 429 }
);
}
const user = await prisma.user.findUnique({ where: { email: normalizedEmail } });
if (!user) {
return NextResponse.json({ ok: true });
}
@@ -20,13 +38,21 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
}
await prisma.verificationToken.deleteMany({ where: { identifier: email } });
const existingToken = await prisma.verificationToken.findFirst({
where: { identifier: normalizedEmail, expires: { gt: new Date() } }
});
if (existingToken) {
return NextResponse.json({ ok: true });
}
await prisma.verificationToken.deleteMany({ where: { identifier: normalizedEmail } });
const token = randomUUID();
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
await prisma.verificationToken.create({
data: {
identifier: email,
identifier: normalizedEmail,
token,
expires
}
@@ -35,7 +61,7 @@ export async function POST(request: Request) {
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
await sendMail({
to: email,
to: normalizedEmail,
subject: "E-Mail verifizieren",
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
});

View File

@@ -55,3 +55,35 @@ export async function GET() {
return NextResponse.json(hydrated, { status: 201 });
}
export async function PATCH(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const icalPastDays = Number(body?.icalPastDays);
if (!Number.isFinite(icalPastDays) || icalPastDays < 0 || icalPastDays > 365) {
return NextResponse.json(
{ error: "iCal-Rückblick ist ungültig." },
{ status: 400 }
);
}
const email = session.user?.email || "";
const view = await prisma.userView.findFirst({
where: { user: { email } }
});
if (!view) {
return NextResponse.json({ error: "Ansicht nicht gefunden." }, { status: 404 });
}
const updated = await prisma.userView.update({
where: { id: view.id },
data: { icalPastDays: Math.floor(icalPastDays) }
});
return NextResponse.json(updated);
}