From 6759411994ea2df3a3e2eb51a9a891ed9298f16e Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 16:18:22 +0000 Subject: [PATCH 1/3] [#40] Add content moderation admin API and hidden content filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 5 +++ src/app/api/admin/hide/route.ts | 67 +++++++++++++++++++++++++++++++ src/app/api/admin/unhide/route.ts | 67 +++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 src/app/api/admin/hide/route.ts create mode 100644 src/app/api/admin/unhide/route.ts 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/src/app/api/admin/hide/route.ts b/src/app/api/admin/hide/route.ts new file mode 100644 index 00000000..46ed0226 --- /dev/null +++ b/src/app/api/admin/hide/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +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 }, + ); + } + + if (authHeader !== `Bearer ${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 (!id || typeof id !== "number") { + return NextResponse.json( + { error: "id must be a number" }, + { status: 400 }, + ); + } + + const supabase = createServerClient(); + 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..10c5c217 --- /dev/null +++ b/src/app/api/admin/unhide/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServerClient } from "../../../../../lib/supabase"; + +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 }, + ); + } + + if (authHeader !== `Bearer ${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 (!id || typeof id !== "number") { + return NextResponse.json( + { error: "id must be a number" }, + { status: 400 }, + ); + } + + const supabase = createServerClient(); + 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 }); +} From da18f88b87cc39be80f414ea5fea8a22e8ccfcf4 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 16:21:24 +0000 Subject: [PATCH 2/3] [#40] Use service-role Supabase client for admin moderation routes Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/supabase.ts | 6 ++++++ src/app/api/admin/hide/route.ts | 4 ++-- src/app/api/admin/unhide/route.ts | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) 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 index 46ed0226..619046f9 100644 --- a/src/app/api/admin/hide/route.ts +++ b/src/app/api/admin/hide/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; export async function POST(req: NextRequest) { // Authenticate with ADMIN_API_KEY @@ -40,7 +40,7 @@ export async function POST(req: NextRequest) { ); } - const supabase = createServerClient(); + const supabase = createServiceRoleClient(); if (!supabase) { return NextResponse.json( { error: "Database unavailable" }, diff --git a/src/app/api/admin/unhide/route.ts b/src/app/api/admin/unhide/route.ts index 10c5c217..e6efa7e9 100644 --- a/src/app/api/admin/unhide/route.ts +++ b/src/app/api/admin/unhide/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { createServerClient } from "../../../../../lib/supabase"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; export async function POST(req: NextRequest) { // Authenticate with ADMIN_API_KEY @@ -40,7 +40,7 @@ export async function POST(req: NextRequest) { ); } - const supabase = createServerClient(); + const supabase = createServiceRoleClient(); if (!supabase) { return NextResponse.json( { error: "Database unavailable" }, From 5f938213b61a561c8d508c2f6c75eea26b59a7bc Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 16:23:28 +0000 Subject: [PATCH 3/3] [#40] Use timing-safe comparison and validate positive integer IDs --- src/app/api/admin/hide/route.ts | 13 ++++++++++--- src/app/api/admin/unhide/route.ts | 13 ++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/app/api/admin/hide/route.ts b/src/app/api/admin/hide/route.ts index 619046f9..ee70d6ba 100644 --- a/src/app/api/admin/hide/route.ts +++ b/src/app/api/admin/hide/route.ts @@ -1,6 +1,12 @@ 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"); @@ -13,7 +19,8 @@ export async function POST(req: NextRequest) { ); } - if (authHeader !== `Bearer ${adminKey}`) { + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : ""; + if (!safeCompare(token, adminKey)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -33,9 +40,9 @@ export async function POST(req: NextRequest) { { status: 400 }, ); } - if (!id || typeof id !== "number") { + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { return NextResponse.json( - { error: "id must be a number" }, + { error: "id must be a positive integer" }, { status: 400 }, ); } diff --git a/src/app/api/admin/unhide/route.ts b/src/app/api/admin/unhide/route.ts index e6efa7e9..a686a6bb 100644 --- a/src/app/api/admin/unhide/route.ts +++ b/src/app/api/admin/unhide/route.ts @@ -1,6 +1,12 @@ 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"); @@ -13,7 +19,8 @@ export async function POST(req: NextRequest) { ); } - if (authHeader !== `Bearer ${adminKey}`) { + const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : ""; + if (!safeCompare(token, adminKey)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -33,9 +40,9 @@ export async function POST(req: NextRequest) { { status: 400 }, ); } - if (!id || typeof id !== "number") { + if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) { return NextResponse.json( - { error: "id must be a number" }, + { error: "id must be a positive integer" }, { status: 400 }, ); }