diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..d0353d0 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,79 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +/** + * Verschluesselung der Git-Access-Tokens. + * + * Format des Chiffrats: v1... (jeweils base64url) + * + * Der Schluessel wird per scrypt aus APP_SECRET abgeleitet. Ohne gesetztes + * APP_SECRET wird ein Entwicklungs-Fallback benutzt – dann warnt die App + * beim Start, weil Tokens sonst faktisch im Klartext liegen. + */ + +const DEV_FALLBACK_SECRET = "nebula-dev-secret-please-set-APP_SECRET"; +const SALT = "nebula.token.v1"; +const VERSION = "v1"; + +let warned = false; + +function secret(): string { + const value = process.env.APP_SECRET; + if (value && value.length >= 16) return value; + + if (!warned) { + warned = true; + console.warn( + "[nebula] APP_SECRET ist nicht (ausreichend) gesetzt – es wird ein unsicherer " + + "Entwicklungsschluessel verwendet. Bitte APP_SECRET in der .env setzen.", + ); + } + return DEV_FALLBACK_SECRET; +} + +function key(): Buffer { + return scryptSync(secret(), SALT, 32); +} + +export function encryptSecret(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key(), iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return [ + VERSION, + iv.toString("base64url"), + cipher.getAuthTag().toString("base64url"), + encrypted.toString("base64url"), + ].join("."); +} + +export function decryptSecret(payload: string): string { + const parts = payload.split("."); + if (parts.length !== 4 || parts[0] !== VERSION) { + throw new Error("Ungueltiges Token-Format"); + } + const [, ivPart, tagPart, dataPart] = parts; + const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(ivPart, "base64url")); + decipher.setAuthTag(Buffer.from(tagPart, "base64url")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(dataPart, "base64url")), + decipher.final(), + ]); + return decrypted.toString("utf8"); +} + +/** Entschluesselt und schluckt Fehler – z. B. wenn APP_SECRET gewechselt wurde. */ +export function tryDecryptSecret(payload: string | null | undefined): string | null { + if (!payload) return null; + try { + return decryptSecret(payload); + } catch { + return null; + } +} + +/** Verkuerzte Darstellung fuer die UI: "ghp_…9f3a". */ +export function tokenHint(token: string): string { + const trimmed = token.trim(); + if (trimmed.length <= 8) return "••••"; + return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..dc9aca4 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,22 @@ +import { PrismaClient } from "@prisma/client"; + +/** + * Im Dev-Modus laedt Next die Module bei jedem Hot-Reload neu. Ohne diesen + * Cache wuerde pro Reload eine neue Verbindung zur Datenbank aufgebaut. + */ +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: + process.env.NODE_ENV === "development" + ? ["warn", "error"] + : ["error"], + }); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} diff --git a/src/lib/projects.ts b/src/lib/projects.ts new file mode 100644 index 0000000..50efa4e --- /dev/null +++ b/src/lib/projects.ts @@ -0,0 +1,55 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { slugify } from "@/lib/utils"; + +/** Erzeugt einen eindeutigen Slug, notfalls mit angehaengtem Zaehler. */ +export async function uniqueSlug(name: string, ignoreId?: string): Promise { + const base = slugify(name) || "projekt"; + let candidate = base; + let counter = 2; + + // In der Praxis endet die Schleife nach wenigen Runden. + for (let attempt = 0; attempt < 50; attempt += 1) { + const existing = await prisma.project.findUnique({ + where: { slug: candidate }, + select: { id: true }, + }); + if (!existing || existing.id === ignoreId) return candidate; + candidate = `${base}-${counter}`; + counter += 1; + } + return `${base}-${Date.now()}`; +} + +/** + * Verbindungsfelder, die an den Browser gehen duerfen. + * tokenCipher ist bewusst nicht dabei. + */ +export const connectionSafeSelect = Prisma.validator()({ + id: true, + name: true, + provider: true, + baseUrl: true, + username: true, + tokenHint: true, + lastCheckedAt: true, + lastCheckOk: true, + lastCheckMessage: true, + createdAt: true, + updatedAt: true, +}); + +export const projectListInclude = Prisma.validator()({ + connection: { select: connectionSafeSelect }, + snapshot: true, + _count: { select: { notes: true } }, +}); + +export const projectDetailInclude = Prisma.validator()({ + connection: { select: connectionSafeSelect }, + snapshot: true, + notes: { + orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], + }, + _count: { select: { notes: true } }, +}); diff --git a/src/lib/status.ts b/src/lib/status.ts new file mode 100644 index 0000000..2366e4c --- /dev/null +++ b/src/lib/status.ts @@ -0,0 +1,98 @@ +import type { ProjectStatus } from "@prisma/client"; + +export type StatusMeta = { + value: ProjectStatus; + label: string; + short: string; + /** Tailwind-Klassen fuer Pills/Badges */ + className: string; + /** Reine Farbe fuer Punkte, Balken und Glows */ + color: string; + description: string; +}; + +export const STATUS_ORDER: ProjectStatus[] = [ + "IDEA", + "PLANNING", + "OPEN", + "IN_DEVELOPMENT", + "DONE", + "ARCHIVED", +]; + +export const STATUS_META: Record = { + IDEA: { + value: "IDEA", + label: "Idee", + short: "Idee", + className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30", + color: "#22d3ee", + description: "Noch ein Gedanke – nichts gebaut, nichts entschieden.", + }, + PLANNING: { + value: "PLANNING", + label: "In Planung", + short: "Planung", + className: "bg-blue-500/12 text-blue-200 ring-blue-400/30", + color: "#60a5fa", + description: "Scope, Stack und Schnitt werden gerade festgelegt.", + }, + OPEN: { + value: "OPEN", + label: "Offen", + short: "Offen", + className: "bg-amber-500/12 text-amber-200 ring-amber-400/30", + color: "#fbbf24", + description: "Startklar, aber aktuell liegt es still.", + }, + IN_DEVELOPMENT: { + value: "IN_DEVELOPMENT", + label: "In Entwicklung", + short: "Aktiv", + className: "bg-violet-500/12 text-violet-200 ring-violet-400/30", + color: "#a78bfa", + description: "Wird gerade aktiv gebaut.", + }, + DONE: { + value: "DONE", + label: "Fertig", + short: "Fertig", + className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30", + color: "#34d399", + description: "Ausgeliefert und abgeschlossen.", + }, + ARCHIVED: { + value: "ARCHIVED", + label: "Archiviert", + short: "Archiv", + className: "bg-slate-500/12 text-slate-300 ring-slate-400/25", + color: "#94a3b8", + description: "Ruht im Archiv – bleibt erhalten, stoert aber nicht mehr.", + }, +}; + +export const PRIORITY_META: Record = { + 1: { label: "Niedrig", color: "#64748b" }, + 2: { label: "Normal", color: "#38bdf8" }, + 3: { label: "Hoch", color: "#fb923c" }, + 4: { label: "Kritisch", color: "#f43f5e" }, +}; + +export type AccentKey = "violet" | "cyan" | "emerald" | "amber" | "rose" | "blue"; + +export const ACCENTS: Record = { + violet: { label: "Violett", from: "#8b5cf6", to: "#d946ef" }, + cyan: { label: "Cyan", from: "#06b6d4", to: "#3b82f6" }, + emerald: { label: "Smaragd", from: "#10b981", to: "#84cc16" }, + amber: { label: "Bernstein", from: "#f59e0b", to: "#f43f5e" }, + rose: { label: "Rose", from: "#f43f5e", to: "#8b5cf6" }, + blue: { label: "Blau", from: "#3b82f6", to: "#6366f1" }, +}; + +export function accentOf(key: string | null | undefined) { + return ACCENTS[(key ?? "violet") as AccentKey] ?? ACCENTS.violet; +} + +export function statusOf(status: ProjectStatus | string): StatusMeta { + return STATUS_META[status as ProjectStatus] ?? STATUS_META.IDEA; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..cabdcb6 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,96 @@ +/** Kleine Helfer ohne Abhaengigkeiten. */ + +export function cn(...classes: Array): string { + return classes.filter(Boolean).join(" "); +} + +export function slugify(input: string): string { + return input + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + timeZone: "Europe/Berlin", +}); + +const DATETIME_FORMAT = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: "Europe/Berlin", +}); + +export function formatDate(value: Date | string | null | undefined): string { + if (!value) return "–"; + const date = typeof value === "string" ? new Date(value) : value; + if (Number.isNaN(date.getTime())) return "–"; + return DATE_FORMAT.format(date); +} + +export function formatDateTime(value: Date | string | null | undefined): string { + if (!value) return "–"; + const date = typeof value === "string" ? new Date(value) : value; + if (Number.isNaN(date.getTime())) return "–"; + return DATETIME_FORMAT.format(date); +} + +/** + * "vor 3 Tagen". Bewusst grob gerundet – kleine Abweichungen zwischen + * Server-Render und Hydration fallen damit nicht auf. + */ +export function formatRelative(value: Date | string | null | undefined): string { + if (!value) return "nie"; + const date = typeof value === "string" ? new Date(value) : value; + if (Number.isNaN(date.getTime())) return "nie"; + + const seconds = Math.round((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return "gerade eben"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `vor ${minutes} Min.`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `vor ${hours} Std.`; + const days = Math.round(hours / 24); + if (days < 31) return `vor ${days} ${days === 1 ? "Tag" : "Tagen"}`; + const months = Math.round(days / 30); + if (months < 12) return `vor ${months} Mon.`; + return `vor ${Math.round(months / 12)} J.`; +} + +export function truncate(value: string, max = 120): string { + if (value.length <= max) return value; + return `${value.slice(0, max - 1).trimEnd()}…`; +} + +export function firstLine(value: string | null | undefined): string { + if (!value) return ""; + return value.split("\n")[0] ?? ""; +} + +export function parseTags(input: string): string[] { + return Array.from( + new Set( + input + .split(",") + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean), + ), + ).slice(0, 12); +} + +export function formatNumber(value: number): string { + return new Intl.NumberFormat("de-DE").format(value); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..1dd9934 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; +const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; +const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const; + +const emptyToNull = (value: unknown) => + typeof value === "string" && value.trim() === "" ? null : value; + +export const projectCreateSchema = z.object({ + name: z.string().trim().min(1, "Name darf nicht leer sein").max(120), + summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + description: z.preprocess(emptyToNull, z.string().trim().max(20000).nullable().optional()), + status: z.enum(STATUS).default("IDEA"), + priority: z.coerce.number().int().min(1).max(4).default(2), + progress: z.coerce.number().int().min(0).max(100).default(0), + accent: z.enum(ACCENTS).default("violet"), + tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]), + favorite: z.boolean().default(false), + repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()), + repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), + repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), + connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), +}); + +export const projectUpdateSchema = projectCreateSchema.partial(); + +export const noteCreateSchema = z.object({ + title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()), + content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000), + pinned: z.boolean().default(false), +}); + +export const noteUpdateSchema = noteCreateSchema.partial(); + +export const connectionCreateSchema = z.object({ + name: z.string().trim().min(1, "Name darf nicht leer sein").max(80), + provider: z.enum(PROVIDERS), + baseUrl: z + .string() + .trim() + .min(1, "Basis-URL fehlt") + .max(300) + .refine((value) => /^https?:\/\//i.test(value), "URL muss mit http:// oder https:// beginnen"), + username: z.preprocess(emptyToNull, z.string().trim().max(120).nullable().optional()), + token: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()), +}); + +export const connectionUpdateSchema = connectionCreateSchema.partial(); + +export type ProjectCreateInput = z.infer; +export type ProjectUpdateInput = z.infer; +export type NoteCreateInput = z.infer; +export type ConnectionCreateInput = z.infer; + +/** Formt Zod-Fehler in ein flaches Objekt fuer die API-Antwort. */ +export function formatZodError(error: z.ZodError): Record { + const result: Record = {}; + for (const issue of error.issues) { + const key = issue.path.join(".") || "_"; + if (!result[key]) result[key] = issue.message; + } + return result; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6a7f04f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,59 @@ +import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client"; +import type { BranchInfo, CommitInfo, ReleaseInfo, TagInfo } from "@/lib/git/types"; + +/** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */ +export interface ConnectionSafe { + id: string; + name: string; + provider: GitProvider; + baseUrl: string; + username: string | null; + tokenHint: string | null; + lastCheckedAt: Date | string | null; + lastCheckOk: boolean | null; + lastCheckMessage: string | null; + createdAt: Date | string; + updatedAt: Date | string; +} + +export interface ConnectionWithCount extends ConnectionSafe { + _count?: { projects: number }; +} + +export interface ProjectListItem extends Project { + connection: ConnectionSafe | null; + snapshot: RepoSnapshot | null; + _count: { notes: number }; +} + +export interface ProjectDetail extends Project { + connection: ConnectionSafe | null; + snapshot: RepoSnapshot | null; + notes: Note[]; + _count: { notes: number }; +} + +export interface RepoListEntry { + fullPath: string; + owner: string; + name: string; + description: string | null; + htmlUrl: string; + defaultBranch: string | null; + visibility: string | null; + updatedAt: string | null; +} + +export type { BranchInfo, CommitInfo, Note, ProjectStatus, ReleaseInfo, RepoSnapshot, TagInfo }; + +/** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */ +export function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : []; +} + +export function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +}