191 lines
4.8 KiB
TypeScript
191 lines
4.8 KiB
TypeScript
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;
|
|
|
|
const asText = (value: unknown) => {
|
|
if (value === null || value === undefined) return "";
|
|
if (typeof value === "string") return value.trim();
|
|
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) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
if (!isAdminSession(session)) {
|
|
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"));
|
|
|
|
if (!(file instanceof File)) {
|
|
return NextResponse.json(
|
|
{ error: "Bitte eine iCal-Datei hochladen." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
if (!categoryId) {
|
|
return NextResponse.json(
|
|
{ error: "Bitte eine Kategorie auswählen." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const category = await prisma.category.findUnique({
|
|
where: { id: categoryId }
|
|
});
|
|
if (!category) {
|
|
return NextResponse.json(
|
|
{ error: "Kategorie nicht gefunden." },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
return NextResponse.json(
|
|
{ error: "Datei ist zu groß (max. 5 MB)." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
let parsed: Record<string, any>;
|
|
try {
|
|
const raw = await file.text();
|
|
parsed = parseICS(raw);
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: "iCal-Datei konnte nicht gelesen werden." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const entries = Object.values(parsed).filter(
|
|
(entry) => entry && entry.type === "VEVENT"
|
|
);
|
|
|
|
if (entries.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: "Keine Termine in der iCal-Datei gefunden." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
let created = 0;
|
|
let duplicates = 0;
|
|
let skipped = 0;
|
|
let recurringSkipped = 0;
|
|
|
|
const creatorEmail = session.user?.email || "";
|
|
|
|
for (const entry of entries) {
|
|
if (entry.rrule) {
|
|
recurringSkipped += 1;
|
|
continue;
|
|
}
|
|
|
|
const title = asText(entry.summary);
|
|
const start = entry.start instanceof Date ? entry.start : null;
|
|
if (!title || !start || Number.isNaN(start.getTime())) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
const end =
|
|
entry.end instanceof Date && !Number.isNaN(entry.end.getTime())
|
|
? entry.end
|
|
: 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: {
|
|
title,
|
|
startAt: start,
|
|
location,
|
|
categoryId
|
|
}
|
|
});
|
|
|
|
if (existing) {
|
|
duplicates += 1;
|
|
continue;
|
|
}
|
|
|
|
await prisma.event.create({
|
|
data: {
|
|
title,
|
|
description,
|
|
location,
|
|
locationLat: geo ? geo.lat : null,
|
|
locationLng: geo ? geo.lng : null,
|
|
startAt: start,
|
|
endAt: end,
|
|
status: "APPROVED",
|
|
createdBy: { connect: { email: creatorEmail } },
|
|
category: { connect: { id: categoryId } }
|
|
}
|
|
});
|
|
|
|
created += 1;
|
|
}
|
|
|
|
return NextResponse.json({
|
|
created,
|
|
duplicates,
|
|
skipped,
|
|
recurringSkipped
|
|
});
|
|
}
|