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,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";
|
||||
|
||||
/** 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<T> =
|
||||
*/
|
||||
export async function readJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
maxBytes: number = MAX_BODY_BYTES,
|
||||
): Promise<BodyResult<T>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
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");
|
||||
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<T = unknown>(
|
||||
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) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user