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:
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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<typeof exportedProjectSchema>;
|
||||||
|
export type ExportFile = z.infer<typeof exportFileSchema>;
|
||||||
|
|
||||||
|
/** 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}`;
|
||||||
|
}
|
||||||
@@ -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<GitConnection, "name" | "provider" | "baseUrl"> | 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<ProjectForExport[]> {
|
||||||
|
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<string, string> = {
|
||||||
|
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(`<sub>${formatDateTime(note.createdAt)}</sub>`, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("---", "");
|
||||||
|
lines.push(`<sub>Exportiert aus ${APP_NAME} am ${formatDateTime(new Date())}.</sub>`, "");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
/** Groesste akzeptierte JSON-Nutzlast. */
|
/** Groesste akzeptierte JSON-Nutzlast im Normalfall. */
|
||||||
const MAX_BODY_BYTES = 256 * 1024;
|
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(
|
export function jsonError(
|
||||||
message: string,
|
message: string,
|
||||||
status: number,
|
status: number,
|
||||||
@@ -34,6 +40,7 @@ export type BodyResult<T> =
|
|||||||
*/
|
*/
|
||||||
export async function readJsonBody<T = unknown>(
|
export async function readJsonBody<T = unknown>(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
maxBytes: number = MAX_BODY_BYTES,
|
||||||
): Promise<BodyResult<T>> {
|
): Promise<BodyResult<T>> {
|
||||||
const contentType = request.headers.get("content-type") ?? "";
|
const contentType = request.headers.get("content-type") ?? "";
|
||||||
if (!contentType.toLowerCase().includes("application/json")) {
|
if (!contentType.toLowerCase().includes("application/json")) {
|
||||||
@@ -44,7 +51,7 @@ export async function readJsonBody<T = unknown>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
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) };
|
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +62,7 @@ export async function readJsonBody<T = unknown>(
|
|||||||
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
|
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) };
|
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user