"use server"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { checkPasswordStrength, createApiKey, destroyAllSessions, getSessionUser, hashPassword, verifyPassword, } from "@/lib/auth"; import { prisma } from "@/lib/db"; import { setSetting } from "@/lib/settings"; import { sendTestNotification } from "@/lib/notify"; import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer"; import { runSync } from "@/lib/sync"; import { connectJellyfin, deleteConnection, syncConnection, testConnection, updateConnection, type SyncReport, } from "@/lib/mediaserver"; /** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */ export type SettingsResult = { ok: boolean; message?: string; secret?: string }; async function run( fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>, ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; try { const result = (await fn(user.id)) ?? {}; revalidatePath("/einstellungen", "layout"); revalidatePath("/", "layout"); return { ok: true, ...result }; } catch (error) { console.error("Einstellungs-Aktion fehlgeschlagen:", error); return { ok: false, message: error instanceof Error ? error.message : "Das hat nicht geklappt.", }; } } async function requireAdminUser() { const user = await getSessionUser(); if (!user || user.role !== "ADMIN") { throw new Error("Dafür fehlen dir die Rechte."); } return user; } // --------------------------------------------------------------------------- // Profil // --------------------------------------------------------------------------- const profileSchema = z.object({ name: z.string().trim().min(1).max(80), region: z.string().trim().length(2).toUpperCase(), publicProfile: z.boolean(), profileSlug: z .string() .trim() .toLowerCase() .regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.") .nullable(), notifyNewEpisode: z.boolean(), notifyRelease: z.boolean(), notifyAvailability: z.boolean(), }); export async function updateProfileAction(data: unknown) { return run(async (userId) => { const parsed = profileSchema.parse(data); if (parsed.publicProfile && !parsed.profileSlug) { throw new Error("Für ein öffentliches Profil braucht es eine Adresse."); } if (parsed.profileSlug) { const taken = await prisma.user.findFirst({ where: { profileSlug: parsed.profileSlug, id: { not: userId } }, select: { id: true }, }); if (taken) throw new Error("Diese Profiladresse ist schon vergeben."); } await prisma.user.update({ where: { id: userId }, data: parsed }); return { message: "Gespeichert" }; }); } const passwordSchema = z.object({ current: z.string().min(1), next: z.string().min(1), repeat: z.string().min(1), }); export async function changePasswordAction(data: unknown) { return run(async (userId) => { const parsed = passwordSchema.parse(data); if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein."); const weak = checkPasswordStrength(parsed.next); if (weak) throw new Error(weak); const user = await prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { passwordHash: true }, }); if (!user.passwordHash) { throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort."); } if (!(await verifyPassword(parsed.current, user.passwordHash))) { throw new Error("Das bisherige Passwort stimmt nicht."); } await prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(parsed.next) }, }); // Nach einem Passwortwechsel fliegen alle anderen Geräte raus. await destroyAllSessions(userId); return { message: "Passwort geändert — bitte neu anmelden." }; }); } // --------------------------------------------------------------------------- // API-Schlüssel // --------------------------------------------------------------------------- export async function createApiKeyAction(name: string) { return run(async (userId) => { const clean = z.string().trim().min(1).max(60).parse(name); const { plain } = await createApiKey(userId, clean); // Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash. return { message: "Schlüssel angelegt", secret: plain }; }); } export async function revokeApiKeyAction(keyId: string) { return run(async (userId) => { await prisma.apiKey.updateMany({ where: { id: keyId, userId }, data: { revokedAt: new Date() }, }); return { message: "Schlüssel zurückgezogen" }; }); } // --------------------------------------------------------------------------- // Benachrichtigungskanäle // --------------------------------------------------------------------------- const channelSchema = z.object({ kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]), name: z.string().trim().min(1).max(60), config: z.record(z.string(), z.string().max(500)).default({}), }); export async function saveChannelAction(data: unknown, channelId?: string) { return run(async (userId) => { const parsed = channelSchema.parse(data); if (channelId) { await prisma.notificationChannel.updateMany({ where: { id: channelId, userId }, data: { kind: parsed.kind, name: parsed.name, config: parsed.config }, }); return { message: "Kanal gespeichert" }; } await prisma.notificationChannel.create({ data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config }, }); return { message: "Kanal angelegt" }; }); } export async function toggleChannelAction(channelId: string, enabled: boolean) { return run(async (userId) => { await prisma.notificationChannel.updateMany({ where: { id: channelId, userId }, data: { enabled }, }); return { message: enabled ? "Kanal aktiv" : "Kanal ruht" }; }); } export async function deleteChannelAction(channelId: string) { return run(async (userId) => { await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } }); return { message: "Kanal entfernt" }; }); } export async function testChannelAction(channelId: string) { return run(async (userId) => { await sendTestNotification(userId, channelId); return { message: "Testnachricht verschickt" }; }); } // --------------------------------------------------------------------------- // Import // --------------------------------------------------------------------------- export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary }; export async function importDataAction( format: "csv" | "trakt", content: string, ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; if (content.length > 8_000_000) { return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." }; } try { const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content); revalidatePath("/bibliothek", "layout"); revalidatePath("/", "layout"); return { ok: true, message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`, summary, }; } catch (error) { console.error("Import fehlgeschlagen:", error); return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." }; } } // --------------------------------------------------------------------------- // Verwaltung // --------------------------------------------------------------------------- export async function setRegistrationAction(enabled: boolean) { return run(async () => { await requireAdminUser(); await setSetting("registrationEnabled", enabled); return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" }; }); } export async function setInstanceNameAction(name: string) { return run(async () => { await requireAdminUser(); await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name)); return { message: "Name gespeichert" }; }); } export async function setPublicProfilesAction(enabled: boolean) { return run(async () => { await requireAdminUser(); await setSetting("publicProfilesEnabled", enabled); return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" }; }); } export async function setUserActiveAction(targetId: string, active: boolean) { return run(async (userId) => { await requireAdminUser(); if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren."); await prisma.user.update({ where: { id: targetId }, data: { isActive: active } }); if (!active) await destroyAllSessions(targetId); revalidatePath("/verwaltung", "layout"); return { message: active ? "Konto freigegeben" : "Konto gesperrt" }; }); } export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") { return run(async (userId) => { await requireAdminUser(); if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern."); if (role === "USER") { const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } }); if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben."); } await prisma.user.update({ where: { id: targetId }, data: { role } }); revalidatePath("/verwaltung", "layout"); return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" }; }); } export async function runSyncAction() { return run(async () => { await requireAdminUser(); const stats = await runSync({ limit: 40 }); revalidatePath("/verwaltung", "layout"); return { message: stats.errors.length === 0 ? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen` : `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`, }; }); } // --------------------------------------------------------------------------- // Medienserver // --------------------------------------------------------------------------- const verbindungSchema = z.object({ baseUrl: z.string().trim().min(1).max(300), username: z.string().trim().min(1).max(120), password: z.string().min(1).max(300), }); export type MediaServerResult = SettingsResult & { pruefung?: { serverName: string; version: string | null; itemCount: number; hasUserData: boolean; withTmdbId: number; }; bericht?: SyncReport; }; export async function connectMediaServerAction(input: { baseUrl: string; username: string; password: string; }): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; const daten = verbindungSchema.safeParse(input); if (!daten.success) return { ok: false, message: "Adresse, Name und Passwort werden gebraucht." }; try { const { pruefung } = await connectJellyfin(user.id, daten.data); revalidatePath("/einstellungen", "layout"); return { ok: true, message: `Verbunden mit ${pruefung.serverName}`, pruefung, }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : "Das hat nicht geklappt.", }; } } export async function testMediaServerAction(connectionId: string): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; try { const pruefung = await testConnection(user.id, connectionId); return { ok: true, message: `${pruefung.serverName} antwortet`, pruefung }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : "Das hat nicht geklappt.", }; } } export async function syncMediaServerAction( connectionId: string, aufnehmen: boolean, ): Promise { const user = await getSessionUser(); if (!user) return { ok: false, message: "Bitte melde dich an." }; try { const bericht = await syncConnection(user.id, connectionId, { aufnehmen }); revalidatePath("/einstellungen", "layout"); revalidatePath("/bibliothek", "layout"); return { ok: true, message: `${bericht.gelesen} Einträge gelesen, ${bericht.bestandGesetzt}× „hab ich“ gesetzt, ` + `${bericht.sichtungenAngelegt} Sichtungen übernommen`, bericht, }; } catch (error) { return { ok: false, message: error instanceof Error ? error.message : "Das hat nicht geklappt.", }; } } export async function updateMediaServerAction( connectionId: string, data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean }, ) { return run(async (userId) => { await updateConnection(userId, connectionId, data); return { message: "Gespeichert" }; }); } export async function deleteMediaServerAction(connectionId: string) { return run(async (userId) => { await deleteConnection(userId, connectionId); return { message: "Verbindung entfernt" }; }); }