From 91c94e738319c3b24bafd57f9089068170009ecf Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 00:33:26 +0200 Subject: [PATCH] feat(export): Projekte exportieren und importieren Bislang lagen alle Daten ausschliesslich in der Datenbank - ein "docker compose down -v" haette alles geloescht, ohne Rueckweg. Export: - alle eigenen Projekte oder ein einzelnes als JSON - ein Projekt zusaetzlich als lesbares Markdown, inklusive letztem bekannten Git-Stand Was bewusst nicht in der Datei steht: - Access Tokens. Die Verbindung wird nur beschrieben (Name, System, Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine Exportdatei landet sonst schnell in einem Download-Ordner oder einem Cloud-Abgleich. - Repo-Snapshots. Reproduzierbar und gross; ein Abgleich holt sie zurueck. - Datenbank-IDs und der Besitzer. Beides gilt nur in der Quellinstanz. Import: - die hochgeladene Datei ist Fremdinhalt und wird vor jeder Verwendung streng gegen ein Zod-Schema geprueft - Abgleich ueber den Slug, drei Strategien: ueberspringen, zusaetzlich anlegen, ersetzen - dryRun liefert dieselbe Auswertung, ohne zu schreiben - Grundlage der Vorschau in der Oberflaeche - Verbindungen werden ueber Name oder Provider samt Basis-URL zugeordnet; fehlt eine, wird das Projekt ohne Verknuepfung angelegt und das gemeldet - beim Ersetzen werden Notizen ausgetauscht statt zusammengefuehrt: das ist vorhersagbar, und ein erneuter Import erzeugt keine Dubletten Die Instanz-Sicherung fuer Administratoren enthaelt dagegen alles, was zum Wiederherstellen noetig ist - Passwort-Hashes und Token-Chiffrate eingeschlossen. Ohne dasselbe APP_SECRET sind die Tokens wertlos. Sitzungen und Passkeys bleiben draussen: erstere sind kurzlebig, letztere haengen an Geraet und rpID. readJsonBody bekommt eine einstellbare Obergrenze - 256 KB reichen fuer eine Exportdatei mit vielen Notizen nicht. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/admin/backup/route.ts | 88 +++++++++ src/app/api/projects/[id]/export/route.ts | 56 ++++++ src/app/api/projects/export/route.ts | 29 +++ src/app/api/projects/import/route.ts | 221 ++++++++++++++++++++++ src/lib/export/format.ts | 77 ++++++++ src/lib/export/project.ts | 148 +++++++++++++++ src/lib/security/request.ts | 13 +- 7 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 src/app/api/admin/backup/route.ts create mode 100644 src/app/api/projects/[id]/export/route.ts create mode 100644 src/app/api/projects/export/route.ts create mode 100644 src/app/api/projects/import/route.ts create mode 100644 src/lib/export/format.ts create mode 100644 src/lib/export/project.ts diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts new file mode 100644 index 0000000..ec25a7a --- /dev/null +++ b/src/app/api/admin/backup/route.ts @@ -0,0 +1,88 @@ +import { requireAdminApi } from "@/lib/auth/guard"; +import { APP_NAME } from "@/lib/config"; +import { prisma } from "@/lib/db"; +import { exportFileName } from "@/lib/export/format"; + +export const dynamic = "force-dynamic"; + +/** + * Vollstaendige Sicherung der Instanz als JSON. + * + * Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt + * deshalb auch Konten und Verbindungen. Was drinsteht: + * + * - Passwoerter nur als scrypt-Hash, nicht im Klartext + * - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos + * - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und + * Passkeys haengen ohnehin am Geraet und an der rpID + * + * Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung + * der Datenbank siehe scripts/backup.* mit pg_dump. + */ +export async function GET() { + const auth = await requireAdminApi(); + if (!auth.ok) return auth.response; + + const [settings, users, connections, projects] = await Promise.all([ + prisma.appSettings.findUnique({ where: { id: "singleton" } }), + prisma.user.findMany({ + select: { + id: true, + username: true, + displayName: true, + email: true, + passwordHash: true, + role: true, + isActive: true, + createdAt: true, + lastLoginAt: true, + }, + orderBy: { username: "asc" }, + }), + prisma.gitConnection.findMany({ + select: { + id: true, + ownerId: true, + name: true, + provider: true, + baseUrl: true, + username: true, + tokenCipher: true, + tokenHint: true, + createdAt: true, + }, + orderBy: { name: "asc" }, + }), + prisma.project.findMany({ + include: { + notes: { orderBy: { createdAt: "asc" } }, + // Snapshots bleiben draussen: reproduzierbar und gross. + }, + orderBy: { name: "asc" }, + }), + ]); + + const payload = { + kind: "nebula.backup", + version: 1, + exportedAt: new Date().toISOString(), + app: APP_NAME, + note: + "Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " + + "Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.", + settings, + users, + connections, + projects, + }; + + const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json"); + + return new Response(JSON.stringify(payload, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/projects/[id]/export/route.ts b/src/app/api/projects/[id]/export/route.ts new file mode 100644 index 0000000..d4b939e --- /dev/null +++ b/src/app/api/projects/[id]/export/route.ts @@ -0,0 +1,56 @@ +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { exportFileName } from "@/lib/export/format"; +import { buildExportFile, toMarkdown } from "@/lib/export/project"; +import { jsonError } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** + * Ein einzelnes Projekt exportieren. + * + * ?format=json (Vorgabe) – zum Wiedereinlesen + * ?format=md – lesbar, zum Archivieren + */ +export async function GET(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const format = new URL(request.url).searchParams.get("format") ?? "json"; + + const project = await prisma.project.findFirst({ + where: { id, ownerId: auth.user.id }, + include: { + notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + connection: { select: { name: true, provider: true, baseUrl: true } }, + snapshot: true, + }, + }); + + if (!project) return jsonError("Projekt nicht gefunden", 404); + + if (format === "md" || format === "markdown") { + const filename = exportFileName(project.slug, new Date(), "md"); + return new Response(toMarkdown(project), { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); + } + + const payload = buildExportFile([project]); + const filename = exportFileName(project.slug, new Date(), "json"); + + return new Response(JSON.stringify(payload, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/projects/export/route.ts b/src/app/api/projects/export/route.ts new file mode 100644 index 0000000..1b68800 --- /dev/null +++ b/src/app/api/projects/export/route.ts @@ -0,0 +1,29 @@ +import { requireUserApi } from "@/lib/auth/guard"; +import { APP_NAME } from "@/lib/config"; +import { exportFileName } from "@/lib/export/format"; +import { buildExportFile, loadProjectsForExport } from "@/lib/export/project"; +import { jsonError } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +/** Alle eigenen Projekte als JSON zum Herunterladen. */ +export async function GET() { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const projects = await loadProjectsForExport(auth.user.id); + if (projects.length === 0) { + return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404); + } + + const payload = buildExportFile(projects); + const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json"); + + return new Response(JSON.stringify(payload, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/app/api/projects/import/route.ts b/src/app/api/projects/import/route.ts new file mode 100644 index 0000000..a78eb35 --- /dev/null +++ b/src/app/api/projects/import/route.ts @@ -0,0 +1,221 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { exportFileSchema, type ExportedProject } from "@/lib/export/format"; +import { uniqueSlug } from "@/lib/projects"; +import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; +import { + IMPORT_MAX_BODY_BYTES, + clientIp, + jsonError, + readJsonBody, + tooManyRequests, +} from "@/lib/security/request"; +import { slugify } from "@/lib/utils"; + +export const dynamic = "force-dynamic"; + +const requestSchema = z.object({ + data: z.unknown(), + /** + * skip – vorhandene Projekte unveraendert lassen + * rename – zusaetzlich anlegen, mit freiem Slug + * overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht) + */ + strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"), + /** Nur pruefen und berichten, nichts schreiben. */ + dryRun: z.boolean().default(true), +}); + +type Action = "create" | "rename" | "overwrite" | "skip" | "error"; + +interface ItemResult { + name: string; + slug: string; + action: Action; + /** Bei "rename" der tatsaechlich vergebene Slug. */ + newSlug?: string; + notes: number; + /** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */ + connection: "matched" | "missing" | "none"; + connectionName?: string; + message?: string; +} + +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const limited = rateLimit( + `import:${clientIp(request)}`, + LIMITS.mutation.limit, + LIMITS.mutation.windowMs, + ); + if (!limited.ok) return tooManyRequests(limited.retryAfter); + + const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES); + if (!body.ok) return body.response; + + const outer = requestSchema.safeParse(body.data); + if (!outer.success) return jsonError("Ungueltige Anfrage.", 422); + + // Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas + // damit passiert. + const parsed = exportFileSchema.safeParse(outer.data.data); + if (!parsed.success) { + const first = parsed.error.issues[0]; + return NextResponse.json( + { + error: + "Die Datei passt nicht zum erwarteten Format. " + + (first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""), + }, + { status: 422 }, + ); + } + + const { strategy, dryRun } = outer.data; + const file = parsed.data; + + // Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen. + const connections = await prisma.gitConnection.findMany({ + where: { ownerId: auth.user.id }, + select: { id: true, name: true, provider: true, baseUrl: true }, + }); + + function matchConnection(described: ExportedProject["connection"]) { + if (!described) return null; + const byName = connections.find( + (candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(), + ); + if (byName) return byName; + return ( + connections.find( + (candidate) => + candidate.provider === described.provider && + candidate.baseUrl.replace(/\/+$/, "").toLowerCase() === + described.baseUrl.replace(/\/+$/, "").toLowerCase(), + ) ?? null + ); + } + + const results: ItemResult[] = []; + + for (const incoming of file.projects) { + const slug = incoming.slug || slugify(incoming.name) || "projekt"; + const connection = matchConnection(incoming.connection); + const connectionState: ItemResult["connection"] = incoming.connection + ? connection + ? "matched" + : "missing" + : "none"; + + const existing = await prisma.project.findFirst({ + where: { slug, ownerId: auth.user.id }, + select: { id: true }, + }); + + const base: ItemResult = { + name: incoming.name, + slug, + action: "create", + notes: incoming.notes.length, + connection: connectionState, + connectionName: incoming.connection?.name, + }; + + if (existing && strategy === "skip") { + results.push({ ...base, action: "skip", message: "Slug bereits vergeben." }); + continue; + } + + const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite"; + + if (dryRun) { + results.push({ + ...base, + action, + ...(action === "rename" ? { newSlug: `${slug}-…` } : {}), + ...(action === "overwrite" + ? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." } + : {}), + }); + continue; + } + + const data = { + name: incoming.name, + summary: incoming.summary ?? null, + description: incoming.description ?? null, + status: incoming.status, + priority: incoming.priority, + progress: incoming.progress, + accent: incoming.accent, + tags: incoming.tags, + favorite: incoming.favorite, + autoSync: incoming.autoSync, + repoUrl: incoming.repoUrl ?? null, + repoOwner: incoming.repoOwner ?? null, + repoName: incoming.repoName ?? null, + connectionId: connection?.id ?? null, + }; + + const notes = incoming.notes.map((note) => ({ + title: note.title ?? null, + content: note.content, + pinned: note.pinned ?? false, + ...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt)) + ? { createdAt: new Date(note.createdAt) } + : {}), + })); + + try { + if (existing && action === "overwrite") { + // Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein + // erneuter Import erzeugt keine Dubletten. + await prisma.$transaction([ + prisma.note.deleteMany({ where: { projectId: existing.id } }), + prisma.project.update({ + where: { id: existing.id }, + data: { ...data, slug, notes: { create: notes } }, + }), + ]); + results.push({ ...base, action: "overwrite" }); + } else { + const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug; + await prisma.project.create({ + data: { + ...data, + ownerId: auth.user.id, + slug: finalSlug, + notes: { create: notes }, + }, + }); + results.push({ + ...base, + action: existing ? "rename" : "create", + ...(existing ? { newSlug: finalSlug } : {}), + }); + } + } catch (error) { + results.push({ + ...base, + action: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + } + + const summary = { + total: results.length, + created: results.filter((entry) => entry.action === "create").length, + renamed: results.filter((entry) => entry.action === "rename").length, + overwritten: results.filter((entry) => entry.action === "overwrite").length, + skipped: results.filter((entry) => entry.action === "skip").length, + errors: results.filter((entry) => entry.action === "error").length, + missingConnections: results.filter((entry) => entry.connection === "missing").length, + }; + + return NextResponse.json({ dryRun, strategy, summary, results }); +} diff --git a/src/lib/export/format.ts b/src/lib/export/format.ts new file mode 100644 index 0000000..cb8c7d9 --- /dev/null +++ b/src/lib/export/format.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +/** + * Austauschformat fuer Projekte. + * + * Bewusst schlank und ohne Geheimnisse: + * - keine Access Tokens. Die Verbindung wird nur beschrieben (Name, System, + * Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine + * Exportdatei landet sonst schnell in einem Download-Ordner oder einem + * Cloud-Abgleich. + * - keine Repo-Snapshots. Die sind reproduzierbar und blaehen die Datei auf; + * ein Abgleich nach dem Import holt sie zurueck. + * - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert + * wurde. + */ + +export const EXPORT_KIND = "nebula.projects"; +export const EXPORT_VERSION = 1; + +const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; +const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; + +export const exportedNoteSchema = z.object({ + title: z.string().max(160).nullable().optional(), + content: z.string().max(20000), + pinned: z.boolean().optional().default(false), + createdAt: z.string().optional(), +}); + +export const exportedConnectionSchema = z.object({ + name: z.string().max(80), + provider: z.enum(PROVIDERS), + baseUrl: z.string().max(300), +}); + +export const exportedProjectSchema = z.object({ + name: z.string().min(1).max(120), + slug: z.string().max(60).optional(), + summary: z.string().max(280).nullable().optional(), + description: z.string().max(20000).nullable().optional(), + status: z.enum(STATUS).optional().default("IDEA"), + priority: z.number().int().min(1).max(4).optional().default(2), + progress: z.number().int().min(0).max(100).optional().default(0), + accent: z.string().max(20).optional().default("violet"), + tags: z.array(z.string().max(30)).max(12).optional().default([]), + favorite: z.boolean().optional().default(false), + autoSync: z.boolean().optional().default(true), + repoUrl: z.string().max(500).nullable().optional(), + repoOwner: z.string().max(200).nullable().optional(), + repoName: z.string().max(200).nullable().optional(), + /** Nur zur Wiedererkennung – ohne Token. */ + connection: exportedConnectionSchema.nullable().optional(), + notes: z.array(exportedNoteSchema).max(500).optional().default([]), + createdAt: z.string().optional(), +}); + +export const exportFileSchema = z.object({ + kind: z.literal(EXPORT_KIND), + version: z.number().int().min(1).max(EXPORT_VERSION), + exportedAt: z.string().optional(), + app: z.string().optional(), + projects: z.array(exportedProjectSchema).min(1).max(1000), +}); + +export type ExportedProject = z.infer; +export type ExportFile = z.infer; + +/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */ +export function exportFileName(prefix: string, date: Date, extension: string): string { + const stamp = date.toISOString().slice(0, 19).replace(/[:T]/g, "-"); + const safe = prefix + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40); + return `${safe || "nebula"}-${stamp}.${extension}`; +} diff --git a/src/lib/export/project.ts b/src/lib/export/project.ts new file mode 100644 index 0000000..9c44344 --- /dev/null +++ b/src/lib/export/project.ts @@ -0,0 +1,148 @@ +import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client"; +import { APP_NAME } from "@/lib/config"; +import { prisma } from "@/lib/db"; +import { PRIORITY_META, statusOf } from "@/lib/status"; +import { formatDateTime } from "@/lib/utils"; +import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format"; + +type ProjectForExport = Project & { + notes: Note[]; + connection: Pick | null; +}; + +/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */ +export function toExportedProject(project: ProjectForExport): ExportedProject { + return { + name: project.name, + slug: project.slug, + summary: project.summary, + description: project.description, + status: project.status, + priority: project.priority, + progress: project.progress, + accent: project.accent, + tags: project.tags, + favorite: project.favorite, + autoSync: project.autoSync, + repoUrl: project.repoUrl, + repoOwner: project.repoOwner, + repoName: project.repoName, + connection: project.connection + ? { + name: project.connection.name, + provider: project.connection.provider, + baseUrl: project.connection.baseUrl, + } + : null, + notes: project.notes.map((note) => ({ + title: note.title, + content: note.content, + pinned: note.pinned, + createdAt: note.createdAt.toISOString(), + })), + createdAt: project.createdAt.toISOString(), + }; +} + +export function buildExportFile(projects: ProjectForExport[]): ExportFile { + return { + kind: EXPORT_KIND, + version: EXPORT_VERSION, + exportedAt: new Date().toISOString(), + app: APP_NAME, + projects: projects.map(toExportedProject), + }; +} + +/** Laedt die Projekte eines Kontos im fuer den Export noetigen Umfang. */ +export async function loadProjectsForExport( + ownerId: string, + projectId?: string, +): Promise { + return prisma.project.findMany({ + where: { ownerId, ...(projectId ? { id: projectId } : {}) }, + include: { + notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + connection: { select: { name: true, provider: true, baseUrl: true } }, + }, + orderBy: { name: "asc" }, + }); +} + +// ── Markdown ───────────────────────────────────────────────── + +const PROVIDER_LABEL: Record = { + GITHUB: "GitHub", + GITLAB: "GitLab", + GITEA: "Gitea", +}; + +/** + * Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage + * fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die + * Datei auch ohne Netz vollstaendig ist. + */ +export function toMarkdown( + project: ProjectForExport & { snapshot: RepoSnapshot | null }, +): string { + const status = statusOf(project.status); + const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; + const lines: string[] = []; + + lines.push(`# ${project.name}`, ""); + if (project.summary) lines.push(`> ${project.summary}`, ""); + + lines.push("| | |", "| --- | --- |"); + lines.push(`| Status | ${status.label} |`); + lines.push(`| Fortschritt | ${project.progress} % |`); + lines.push(`| Prioritaet | ${priority.label} |`); + if (project.tags.length) { + lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`); + } + lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`); + if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`); + if (project.connection) { + const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider; + lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`); + } + lines.push(""); + + if (project.description) { + lines.push("## Beschreibung", "", project.description, ""); + } + + const snapshot = project.snapshot; + if (snapshot) { + lines.push("## Stand des Repositories", ""); + lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, ""); + lines.push("| | |", "| --- | --- |"); + if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`); + if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`); + lines.push(`| Branches | ${snapshot.branchCount} |`); + lines.push(`| Tags | ${snapshot.tagCount} |`); + lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`); + lines.push(`| Offene Issues | ${snapshot.openIssues} |`); + if (snapshot.lastCommitSha) { + lines.push( + `| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` + + `${snapshot.lastCommitMessage ?? ""} |`, + ); + } + lines.push(""); + } + + if (project.notes.length) { + lines.push("## Notizen", ""); + for (const note of project.notes) { + const pin = note.pinned ? " 📌" : ""; + lines.push(`### ${note.title ?? "Ohne Titel"}${pin}`, ""); + lines.push(note.content, ""); + lines.push(`${formatDateTime(note.createdAt)}`, ""); + } + } + + lines.push("---", ""); + lines.push(`Exportiert aus ${APP_NAME} am ${formatDateTime(new Date())}.`, ""); + + return lines.join("\n"); +} diff --git a/src/lib/security/request.ts b/src/lib/security/request.ts index f2e30e4..7ac0b9a 100644 --- a/src/lib/security/request.ts +++ b/src/lib/security/request.ts @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; -/** Groesste akzeptierte JSON-Nutzlast. */ +/** Groesste akzeptierte JSON-Nutzlast im Normalfall. */ const MAX_BODY_BYTES = 256 * 1024; +/** + * Groessere Obergrenze fuer den Import: eine Exportdatei mit vielen Notizen + * sprengt 256 KB schnell. + */ +export const IMPORT_MAX_BODY_BYTES = 8 * 1024 * 1024; + export function jsonError( message: string, status: number, @@ -34,6 +40,7 @@ export type BodyResult = */ export async function readJsonBody( request: Request, + maxBytes: number = MAX_BODY_BYTES, ): Promise> { const contentType = request.headers.get("content-type") ?? ""; if (!contentType.toLowerCase().includes("application/json")) { @@ -44,7 +51,7 @@ export async function readJsonBody( } const declaredLength = Number(request.headers.get("content-length") ?? "0"); - if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) { + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { return { ok: false, response: jsonError("Anfrage zu gross.", 413) }; } @@ -55,7 +62,7 @@ export async function readJsonBody( return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) }; } - if (raw.length > MAX_BODY_BYTES) { + if (raw.length > maxBytes) { return { ok: false, response: jsonError("Anfrage zu gross.", 413) }; }