Repository Public
1"use server";23import { revalidatePath } from "next/cache";4import { redirect } from "next/navigation";5import QRCode from "qrcode";6import { audit } from "@/lib/audit";7import { clientIpHash, createSession, destroySession, markMfaPassed, newTotpSecret, requireRole, sessionState, totpUri, verifyTotp, type Role } from "@/lib/auth";8import { sql, j } from "@/lib/db";9import { hashPassword, passwordProblem, randomPassword, verifyPassword } from "@/lib/password";10import { rateLimit } from "@/lib/ratelimit";11import * as ops from "@/lib/studio-ops";1213const str = (f: FormData, k: string) => String(f.get(k) ?? "").trim();14const withMsg = (path: string, key: "ok" | "err", msg: string) => `${path}${path.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(msg.slice(0, 300))}`;1516/** Runs a mutation, then returns to `back` with a success or error notice. Authorization happens inside `fn`. */17async function act(back: string, ok: string, fn: () => Promise<string | void>) {18 let target = back, message = ok;19 try { const r = await fn(); if (r) target = r; }20 catch (e) {21 if ((e as { digest?: string }).digest?.startsWith("NEXT_REDIRECT")) throw e;22 redirect(withMsg(back, "err", (e as Error).message || "The action failed"));23 }24 revalidatePath("/studio", "layout");25 redirect(message ? withMsg(target, "ok", message) : target);26}2728// ───────────── authentication ─────────────29export async function login(f: FormData) {30 const email = str(f, "email").toLowerCase(), password = String(f.get("password") ?? "");31 const ip = await clientIpHash();32 const allowed = (await rateLimit(`login:ip:${ip}`, 5, 60)) && (await rateLimit(`login:acct:${email}`, 10, 3600));33 if (!allowed) redirect(withMsg("/studio/login", "err", "Too many attempts. Wait a minute and try again."));34 const [u] = await sql<{ id: string; password_hash: string; totp_enabled: boolean }[]>`SELECT id, password_hash, totp_enabled FROM users WHERE email = ${email}`;35 // Verify against a dummy hash when the account does not exist, so timing does not reveal it.36 const ok = await verifyPassword(password, u?.password_hash ?? "scrypt$32768$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=");37 if (!u || !ok) { await audit(null, "login.failed", "user", email); redirect(withMsg("/studio/login", "err", "Wrong email or password.")); }38 await createSession(u.id, false);39 await sql`UPDATE users SET last_login_at = now() WHERE id = ${u.id}`;40 await audit(u.id, "login.password", "user", u.id);41 redirect(u.totp_enabled ? "/studio/login?step=mfa" : "/studio");42}43export async function verifyMfa(f: FormData) {44 const { pendingMfa } = await sessionState();45 if (!pendingMfa) redirect("/studio/login");46 if (!(await rateLimit(`mfa:${pendingMfa.id}`, 6, 300))) redirect(withMsg("/studio/login?step=mfa", "err", "Too many codes. Wait five minutes."));47 const [u] = await sql<{ totp_secret: string | null }[]>`SELECT totp_secret FROM users WHERE id = ${pendingMfa.id}`;48 if (!u?.totp_secret || !verifyTotp(u.totp_secret, str(f, "code").replace(/\s/g, ""))) { await audit(pendingMfa.id, "login.mfa_failed", "user", pendingMfa.id); redirect(withMsg("/studio/login?step=mfa", "err", "That code is not valid.")); }49 await markMfaPassed();50 await audit(pendingMfa.id, "login.mfa", "user", pendingMfa.id);51 redirect("/studio");52}53export async function logout() { const { user, pendingMfa } = await sessionState(); await destroySession(); await audit((user ?? pendingMfa)?.id ?? null, "logout"); redirect("/studio/login"); }5455export async function changePassword(f: FormData) {56 const user = await requireRole("viewer", { allowPasswordChange: true });57 await act("/studio/account", "Password changed.", async () => {58 const [u] = await sql<{ password_hash: string }[]>`SELECT password_hash FROM users WHERE id = ${user.id}`;59 if (!(await verifyPassword(String(f.get("current") ?? ""), u.password_hash))) throw new Error("The current password is wrong.");60 const next = String(f.get("next") ?? "");61 const problem = passwordProblem(next);62 if (problem) throw new Error(problem);63 if (next !== String(f.get("confirm") ?? "")) throw new Error("The two new passwords do not match.");64 await sql`UPDATE users SET password_hash = ${await hashPassword(next)}, must_change_password = false WHERE id = ${user.id}`;65 await audit(user.id, "password.change", "user", user.id);66 });67}68export async function startTotp() {69 const user = await requireRole("viewer");70 const secret = newTotpSecret();71 await sql`UPDATE users SET totp_secret = ${secret}, totp_enabled = false WHERE id = ${user.id}`;72 redirect("/studio/account?enroll=1");73}74export async function totpQr(): Promise<{ svg: string; secret: string } | null> {75 const user = await requireRole("viewer");76 const [u] = await sql<{ totp_secret: string | null; totp_enabled: boolean }[]>`SELECT totp_secret, totp_enabled FROM users WHERE id = ${user.id}`;77 if (!u?.totp_secret || u.totp_enabled) return null;78 return { svg: await QRCode.toString(totpUri(user.email, u.totp_secret), { type: "svg", margin: 1, width: 200 }), secret: u.totp_secret };79}80export async function confirmTotp(f: FormData) {81 const user = await requireRole("viewer");82 await act("/studio/account", "Two-factor authentication is on.", async () => {83 const [u] = await sql<{ totp_secret: string | null }[]>`SELECT totp_secret FROM users WHERE id = ${user.id}`;84 if (!u?.totp_secret || !verifyTotp(u.totp_secret, str(f, "code").replace(/\s/g, ""))) throw new Error("That code is not valid. Check the clock of your phone and try again.");85 await sql`UPDATE users SET totp_enabled = true WHERE id = ${user.id}`;86 await markMfaPassed();87 await audit(user.id, "totp.enable", "user", user.id);88 });89}90export async function disableTotp(f: FormData) {91 const user = await requireRole("viewer");92 await act("/studio/account", "Two-factor authentication is off.", async () => {93 const [u] = await sql<{ password_hash: string }[]>`SELECT password_hash FROM users WHERE id = ${user.id}`;94 if (!(await verifyPassword(String(f.get("password") ?? ""), u.password_hash))) throw new Error("The password is wrong.");95 await sql`UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = ${user.id}`;96 await audit(user.id, "totp.disable", "user", user.id);97 });98}99100// ───────────── projects ─────────────101export async function createProject(f: FormData) {102 const user = await requireRole("editor");103 await act("/studio/projects/new", "", async () => {104 const { id } = await ops.createProject(user, Object.fromEntries(["theme", "objective", "audience", "mood", "genre", "constraints", "language", "seriesSlug", "shots", "mode", "quality"].map((k) => [k, str(f, k) || undefined])), { idempotencyKey: str(f, "nonce") });105 return `/studio/projects/${id}`;106 });107}108const P = (id: string) => `/studio/projects/${id}`;109export async function runStage(f: FormData) {110 const user = await requireRole("editor");111 const id = str(f, "project"), stage = str(f, "stage");112 await act(P(id), `Stage "${stage}" queued.`, async () => { await ops.runStage(user, id, stage, { fresh: f.get("fresh") === "1" }); });113}114export async function selectPitch(f: FormData) {115 const user = await requireRole("editor");116 const id = str(f, "project");117 await act(P(id), "Pitch selected. Run the script stage to write it.", () => ops.selectPitch(user, id, str(f, "candidate")));118}119export async function setMode(f: FormData) {120 const user = await requireRole("editor");121 const id = str(f, "project"), mode = str(f, "mode");122 await act(P(id), "Autonomy mode updated.", async () => {123 if (!["manual", "assisted", "autonomous"].includes(mode)) throw new Error("Unknown mode");124 await sql`UPDATE projects SET mode = ${mode}, updated_at = now() WHERE id = ${id}`;125 await audit(user.id, "project.mode", "project", id, { mode });126 });127}128export async function updateShot(f: FormData) {129 const user = await requireRole("editor");130 const id = str(f, "project"), shot = str(f, "shot");131 await act(`${P(id)}?shot=${shot}#shot-${shot}`, "", async () => {132 const changed = await ops.updateShot(user, shot, { narration: str(f, "narration"), narrationAlt: str(f, "narrationAlt"), imagePrompt: str(f, "imagePrompt"), videoPrompt: str(f, "videoPrompt") });133 return withMsg(`${P(id)}?shot=${shot}`, "ok", changed.length ? `Saved a new version (${changed.join(", ")}). Regenerate to apply it.` : "Nothing changed.") + `#shot-${shot}`;134 });135}136export async function regenerateShot(f: FormData) {137 const user = await requireRole("editor");138 const id = str(f, "project"), shot = str(f, "shot"), what = str(f, "what") as "image" | "video" | "voice";139 await act(`${P(id)}?shot=${shot}`, "", async () => {140 if (!["image", "video", "voice"].includes(what)) throw new Error("Unknown element");141 const r = await ops.regenerateShot(user, shot, what, { approve: f.get("approve") === "1" });142 return withMsg(`${P(id)}?shot=${shot}`, "ok", `Regenerating the ${what} of this shot (stage "${r.stage}" queued).`) + `#shot-${shot}`;143 });144}145export async function moveShot(f: FormData) {146 const user = await requireRole("editor");147 const id = str(f, "project");148 await act(P(id), "Shots reordered. Re-run assembly to apply.", () => ops.moveShot(user, str(f, "shot"), f.get("dir") === "-1" ? -1 : 1));149}150export async function approveFilm(f: FormData) {151 const user = await requireRole("editor");152 const id = str(f, "project");153 await act(P(id), "Film approved. It can now be published.", () => ops.approve(user, id, { override: f.get("override") === "1", note: str(f, "note") }));154}155export async function rejectFilm(f: FormData) {156 const user = await requireRole("editor");157 const id = str(f, "project");158 await act(P(id), "Film rejected.", () => ops.reject(user, id, str(f, "note")));159}160export async function publishFilm(f: FormData) {161 const user = await requireRole("editor");162 const id = str(f, "project"), when = str(f, "scheduleFor");163 await act(P(id), when ? "Release scheduled." : "Film published.", async () => {164 const d = when ? new Date(when) : null;165 if (d && Number.isNaN(d.getTime())) throw new Error("Invalid release date");166 await ops.publish(user, id, { scheduleFor: d, featured: f.get("featured") === "1" });167 });168}169export async function archiveProject(f: FormData) {170 const user = await requireRole("editor");171 const id = str(f, "project");172 await act("/studio/projects", "Project archived.", async () => {173 const hit = await sql`UPDATE projects SET status = 'archived', updated_at = now() WHERE id = ${id} AND status <> 'published' AND running_stage IS NULL RETURNING id`;174 if (!hit.length) throw new Error("A published or running project cannot be archived.");175 await audit(user.id, "project.archive", "project", id);176 });177}178179// ───────────── Claude Code director ─────────────180export async function startDirector(f: FormData) {181 const user = await requireRole("editor");182 await act("/studio/director", "", async () => {183 const r = await ops.startDirector(user, { idea: str(f, "idea"), language: str(f, "language"), shots: Number(str(f, "shots")), budget: Number(str(f, "budget")) });184 return `/studio/director?run=${r.runId}`;185 });186}187export async function stopDirector(f: FormData) {188 const user = await requireRole("editor");189 await act(`/studio/director?run=${str(f, "run")}`, "Stop requested: the director finishes its current call and stops.", () => ops.stopDirector(user, str(f, "run")));190}191192// ───────────── films, moderation ─────────────193export async function filmStatus(f: FormData) {194 const user = await requireRole("editor");195 const status = str(f, "status") as "unpublished" | "hold" | "published";196 await act("/studio/films", `Film is now ${status}.`, async () => {197 if (!["unpublished", "hold", "published"].includes(status)) throw new Error("Unknown status");198 await ops.changeFilmStatus(user, str(f, "film"), status, str(f, "note"));199 });200}201export async function featureFilm(f: FormData) {202 const user = await requireRole("editor");203 await act("/studio/films", "Hero film updated.", async () => { await sql`UPDATE films SET featured = (id = ${str(f, "film")})`; await audit(user.id, "film.feature", "film", str(f, "film")); });204}205export async function resolveReport(f: FormData) {206 const user = await requireRole("editor");207 await act("/studio/reports", "Report updated.", async () => {208 const status = f.get("status") === "dismissed" ? "dismissed" : "resolved";209 await sql`UPDATE reports SET status = ${status}, resolved_by = ${user.id} WHERE id = ${str(f, "report")}`;210 await audit(user.id, `report.${status}`, "report", str(f, "report"));211 });212}213214// ───────────── policy, schedule, users ─────────────215export async function killSwitch(f: FormData) {216 const user = await requireRole("editor");217 const on = f.get("on") === "1";218 await act(str(f, "back") || "/studio", on ? "Kill switch ON: no new paid job will start." : "Kill switch off.", async () => { await ops.setKillSwitch(user, on); });219}220export async function savePolicy(f: FormData) {221 const user = await requireRole("owner");222 await act("/studio/settings", "Policy saved.", async () => {223 const num = (k: string, min: number, max: number) => { const v = Number(str(f, k)); if (!Number.isFinite(v) || v < min || v > max) throw new Error(`"${k}" must be between ${min} and ${max}`); return v; };224 const mode = str(f, "default_mode");225 if (!["manual", "assisted", "autonomous"].includes(mode)) throw new Error("Unknown default mode");226 const p = {227 per_shot_usd: num("per_shot_usd", 0, 100), per_film_usd: num("per_film_usd", 0, 500), per_day_usd: num("per_day_usd", 0, 2000), per_month_usd: num("per_month_usd", 0, 20000),228 max_regenerations: Math.round(num("max_regenerations", 0, 10)), concurrency: Math.round(num("concurrency", 1, 8)), films_per_day: Math.round(num("films_per_day", 0, 10)),229 qa_threshold: num("qa_threshold", 0, 10), auto_publish: f.get("auto_publish") === "1", scheduler_enabled: f.get("scheduler_enabled") === "1", default_mode: mode,230 };231 await sql`UPDATE budget_policy SET ${sql(p)}, updated_at = now() WHERE id = 1`;232 const fx = Number(str(f, "fx"));233 if (str(f, "fx") && Number.isFinite(fx) && fx > 0.5 && fx < 3) {234 const [cur] = await sql<{ value: { rate: number } }[]>`SELECT value FROM settings WHERE key = 'fx_usd_cad'`;235 if (cur?.value.rate !== fx) await sql`INSERT INTO settings (key, value) VALUES ('fx_usd_cad', ${j({ rate: fx, at: new Date().toISOString(), by: user.email })}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`;236 }237 await audit(user.id, "policy.update", "policy", "1", p);238 });239}240export async function addSchedule(f: FormData) {241 const user = await requireRole("editor");242 await act("/studio/schedule", "Programme added.", async () => {243 const [s] = await sql<{ id: string }[]>`SELECT id FROM series WHERE slug = ${str(f, "series")}`;244 if (!s) throw new Error("Unknown series");245 const days = f.getAll("weekdays").map(Number).filter((d) => d >= 0 && d <= 6);246 if (!days.length) throw new Error("Choose at least one weekday");247 const hour = Math.min(23, Math.max(0, Number(str(f, "hour")) || 9));248 const lang = ["en", "fr", "bilingual"].includes(str(f, "language")) ? str(f, "language") : "en";249 const mode = ["assisted", "autonomous"].includes(str(f, "mode")) ? str(f, "mode") : "autonomous";250 await sql`INSERT INTO schedules (series_id, theme, language, weekdays, hour, mode) VALUES (${s.id}, ${str(f, "theme").slice(0, 400)}, ${lang}, ${days}, ${hour}, ${mode})`;251 await audit(user.id, "schedule.add", "series", s.id, { days, hour, mode });252 });253}254export async function toggleSchedule(f: FormData) {255 const user = await requireRole("editor");256 await act("/studio/schedule", "Programme updated.", async () => {257 if (f.get("delete") === "1") await sql`DELETE FROM schedules WHERE id = ${str(f, "schedule")}`;258 else await sql`UPDATE schedules SET enabled = NOT enabled WHERE id = ${str(f, "schedule")}`;259 await audit(user.id, f.get("delete") === "1" ? "schedule.delete" : "schedule.toggle", "schedule", str(f, "schedule"));260 });261}262export async function addUser(f: FormData) {263 const owner = await requireRole("owner");264 let password = "";265 await act("/studio/settings", "", async () => {266 const email = str(f, "email").toLowerCase(), role = str(f, "role") as Role;267 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error("Invalid email");268 if (!["editor", "viewer"].includes(role)) throw new Error("Role must be editor or viewer");269 password = randomPassword();270 await sql`INSERT INTO users (email, name, role, password_hash, must_change_password) VALUES (${email}, ${str(f, "name").slice(0, 80)}, ${role}, ${await hashPassword(password)}, true)`;271 await audit(owner.id, "user.create", "user", email, { role });272 // Shown once, to the owner only; the user must replace it at first login.273 return withMsg("/studio/settings", "ok", `User created. One-time password (shown once): ${password}`);274 });275}276export async function removeUser(f: FormData) {277 const owner = await requireRole("owner");278 await act("/studio/settings", "User removed.", async () => {279 const hit = await sql`DELETE FROM users WHERE id = ${str(f, "user")} AND role <> 'owner' RETURNING id`;280 if (!hit.length) throw new Error("The owner account cannot be removed.");281 await audit(owner.id, "user.delete", "user", str(f, "user"));282 });283}284