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) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 00:33:26 +02:00
co-authored by Claude Opus 5
parent bad90753d0
commit 91c94e7383
7 changed files with 629 additions and 3 deletions
+56
View File
@@ -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",
},
});
}