SPB Git

Repository Public

actions.ts 17.4 KB 284 lines typescript

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
Files main
Menu

Theme