diff --git a/.env.example b/.env.example index 7c7eee5d..3108b9b1 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,8 @@ DEPLOYER_PRIVATE_KEY= # App Config # ----------------------------------------------------------------------------- NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# Admin (Content Moderation) +# ----------------------------------------------------------------------------- +ADMIN_API_KEY= diff --git a/lib/supabase.ts b/lib/supabase.ts index 61927afd..293f405f 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -11,6 +11,12 @@ export const supabase: SupabaseClient | null = // Server-side client (service role, bypasses RLS) export function createServerClient(): SupabaseClient | null { + return createServiceRoleClient(); +} + +// Explicit service-role client for admin / privileged operations. +// Uses SUPABASE_SERVICE_ROLE_KEY — never expose to the browser. +export function createServiceRoleClient(): SupabaseClient | null { const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; if (!supabaseUrl || !serviceRoleKey) return null; diff --git a/src/app/api/admin/hide/route.ts b/src/app/api/admin/hide/route.ts new file mode 100644 index 00000000..ee70d6ba --- /dev/null +++ b/src/app/api/admin/hide/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; + +function safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +export async function POST(req: NextRequest) { + // Authenticate with ADMIN_API_KEY + const authHeader = req.headers.get("authorization"); + const adminKey = process.env.ADMIN_API_KEY; + + if (!adminKey) { + return NextResponse.json( + { error: "Server misconfigured" }, + { status: 500 }, + ); + } + + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : ""; + if (!safeCompare(token, adminKey)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Parse body + let body: { type: string; id: number }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { type, id } = body; + + if (!type || !["storyline", "plot"].includes(type)) { + return NextResponse.json( + { error: 'type must be "storyline" or "plot"' }, + { status: 400 }, + ); + } + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { + return NextResponse.json( + { error: "id must be a positive integer" }, + { status: 400 }, + ); + } + + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json( + { error: "Database unavailable" }, + { status: 500 }, + ); + } + + const table = type === "storyline" ? "storylines" : "plots"; + const idColumn = type === "storyline" ? "storyline_id" : "id"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: dbError } = await (supabase.from(table) as any) + .update({ hidden: true }) + .eq(idColumn, id); + + if (dbError) { + return NextResponse.json( + { error: `Database error: ${dbError.message}` }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true, action: "hide", type, id }); +} diff --git a/src/app/api/admin/unhide/route.ts b/src/app/api/admin/unhide/route.ts new file mode 100644 index 00000000..a686a6bb --- /dev/null +++ b/src/app/api/admin/unhide/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; + +function safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +export async function POST(req: NextRequest) { + // Authenticate with ADMIN_API_KEY + const authHeader = req.headers.get("authorization"); + const adminKey = process.env.ADMIN_API_KEY; + + if (!adminKey) { + return NextResponse.json( + { error: "Server misconfigured" }, + { status: 500 }, + ); + } + + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : ""; + if (!safeCompare(token, adminKey)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Parse body + let body: { type: string; id: number }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { type, id } = body; + + if (!type || !["storyline", "plot"].includes(type)) { + return NextResponse.json( + { error: 'type must be "storyline" or "plot"' }, + { status: 400 }, + ); + } + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { + return NextResponse.json( + { error: "id must be a positive integer" }, + { status: 400 }, + ); + } + + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json( + { error: "Database unavailable" }, + { status: 500 }, + ); + } + + const table = type === "storyline" ? "storylines" : "plots"; + const idColumn = type === "storyline" ? "storyline_id" : "id"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: dbError } = await (supabase.from(table) as any) + .update({ hidden: false }) + .eq(idColumn, id); + + if (dbError) { + return NextResponse.json( + { error: `Database error: ${dbError.message}` }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true, action: "unhide", type, id }); +}