diff --git a/.env.example b/.env.example index 3108b9b1..6744b391 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,11 @@ DEPLOYER_PRIVATE_KEY= # ----------------------------------------------------------------------------- NEXT_PUBLIC_APP_URL=http://localhost:3000 +# ----------------------------------------------------------------------------- +# Farcaster Identity (Neynar API — optional, for writer profile display) +# ----------------------------------------------------------------------------- +NEYNAR_API_KEY= + # ----------------------------------------------------------------------------- # Admin (Content Moderation) # ----------------------------------------------------------------------------- diff --git a/lib/contracts/constants.ts b/lib/contracts/constants.ts index 8634ba1e..453a9597 100644 --- a/lib/contracts/constants.ts +++ b/lib/contracts/constants.ts @@ -35,6 +35,9 @@ export const PLOT_TOKEN = (IS_TESTNET ? "0x4200000000000000000000000000000000000006" : "0x0000000000000000000000000000000000000000") as `0x${string}`; +/** Human-readable label for the reserve token */ +export const RESERVE_LABEL = IS_TESTNET ? "WETH" : "$PLOT"; + // --------------------------------------------------------------------------- // Mint Club V2 // --------------------------------------------------------------------------- diff --git a/src/app/api/admin/auth.ts b/src/app/api/admin/auth.ts new file mode 100644 index 00000000..0835d9d7 --- /dev/null +++ b/src/app/api/admin/auth.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import { createServiceRoleClient } from "../../../../lib/supabase"; + +/** + * Constant-time string comparison that does NOT leak length. + * Pads the shorter input with zeros so timingSafeEqual always runs on equal-length buffers. + */ +function safeCompare(a: string, b: string): boolean { + const maxLen = Math.max(a.length, b.length); + const bufA = Buffer.alloc(maxLen); + const bufB = Buffer.alloc(maxLen); + Buffer.from(a).copy(bufA); + Buffer.from(b).copy(bufB); + return a.length === b.length && timingSafeEqual(bufA, bufB); +} + +/** + * Shared handler for admin hide/unhide operations. + * Authenticates via ADMIN_API_KEY, validates input, and toggles the hidden flag. + */ +export async function handleModeration( + req: NextRequest, + action: "hide" | "unhide", +): Promise { + 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 }); + } + + 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"; + const hidden = action === "hide"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: dbError } = await (supabase.from(table) as any) + .update({ hidden }) + .eq(idColumn, id); + + if (dbError) { + return NextResponse.json( + { error: `Database error: ${dbError.message}` }, + { status: 500 }, + ); + } + + console.log(`[admin] ${action} ${type} id=${id} at ${new Date().toISOString()}`); + + return NextResponse.json({ success: true, action, type, id }); +} diff --git a/src/app/api/admin/hide/route.ts b/src/app/api/admin/hide/route.ts index ee70d6ba..6511ab15 100644 --- a/src/app/api/admin/hide/route.ts +++ b/src/app/api/admin/hide/route.ts @@ -1,74 +1,6 @@ -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)); -} +import { type NextRequest } from "next/server"; +import { handleModeration } from "../auth"; 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 }); + return handleModeration(req, "hide"); } diff --git a/src/app/api/admin/unhide/route.ts b/src/app/api/admin/unhide/route.ts index a686a6bb..8ba4eb69 100644 --- a/src/app/api/admin/unhide/route.ts +++ b/src/app/api/admin/unhide/route.ts @@ -1,74 +1,6 @@ -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)); -} +import { type NextRequest } from "next/server"; +import { handleModeration } from "../auth"; 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 }); + return handleModeration(req, "unhide"); } diff --git a/src/app/dashboard/reader/page.tsx b/src/app/dashboard/reader/page.tsx index 15bc3643..94de1dd3 100644 --- a/src/app/dashboard/reader/page.tsx +++ b/src/app/dashboard/reader/page.tsx @@ -5,6 +5,7 @@ import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import { supabase, type Donation } from "../../../../lib/supabase"; import { ReaderPortfolio } from "../../../components/ReaderPortfolio"; +import { WriterIdentityClient } from "../../../components/WriterIdentityClient"; import { formatUnits } from "viem"; import { ConnectWallet } from "../../../components/ConnectWallet"; @@ -76,6 +77,9 @@ export default function ReaderDashboard() {

Reader Dashboard

+

+ +

diff --git a/src/app/dashboard/writer/page.tsx b/src/app/dashboard/writer/page.tsx index d20c5df2..877bf66d 100644 --- a/src/app/dashboard/writer/page.tsx +++ b/src/app/dashboard/writer/page.tsx @@ -6,6 +6,7 @@ import { supabase, type Storyline } from "../../../../lib/supabase"; import { DeadlineCountdown } from "../../../components/DeadlineCountdown"; import { ClaimRoyalties } from "../../../components/ClaimRoyalties"; import { WriterTradingStats } from "../../../components/WriterTradingStats"; +import { WriterIdentityClient } from "../../../components/WriterIdentityClient"; import Link from "next/link"; import { ConnectWallet } from "../../../components/ConnectWallet"; import { type Address } from "viem"; @@ -51,6 +52,8 @@ export default function WriterDashboard() { Writer Dashboard

+ + {" — "} {storylines.length}{" "} {storylines.length === 1 ? "storyline" : "storylines"}

diff --git a/src/app/story/[storylineId]/og/route.tsx b/src/app/story/[storylineId]/og/route.tsx index 6edf9a13..254950b0 100644 --- a/src/app/story/[storylineId]/og/route.tsx +++ b/src/app/story/[storylineId]/og/route.tsx @@ -2,7 +2,7 @@ import { ImageResponse } from "next/og"; import { type Address } from "viem"; import { createServerClient, type Storyline } from "../../../../../lib/supabase"; import { getTokenPrice } from "../../../../../lib/price"; -import { IS_TESTNET } from "../../../../../lib/contracts/constants"; +import { RESERVE_LABEL } from "../../../../../lib/contracts/constants"; import { truncateAddress } from "../../../../../lib/utils"; export const runtime = "edge"; @@ -40,7 +40,7 @@ export async function GET( ? await getTokenPrice(sl.token_address as Address) : null; - const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const reserveLabel = RESERVE_LABEL; const priceDisplay = priceInfo ? `${priceInfo.pricePerToken} ${reserveLabel}` : null; @@ -112,7 +112,7 @@ export async function GET( ), { width: 1200, - height: 800, + height: 630, }, ); } diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index 1ec97aa9..e25828eb 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -9,7 +9,7 @@ import { RatingWidget } from "../../../components/RatingWidget"; import { RatingSummary } from "../../../components/RatingSummary"; import { ShareToFarcaster } from "../../../components/ShareToFarcaster"; import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price"; -import { IS_TESTNET } from "../../../../lib/contracts/constants"; +import { RESERVE_LABEL } from "../../../../lib/contracts/constants"; import { type Address } from "viem"; import { truncateAddress } from "../../../../lib/utils"; import { AgentBadge } from "../../../components/AgentBadge"; @@ -48,7 +48,7 @@ export async function generateMetadata({ const priceInfo = sl.token_address ? await getTokenPrice(sl.token_address as Address) : null; - const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const reserveLabel = RESERVE_LABEL; const priceSuffix = priceInfo ? ` — Price: ${priceInfo.pricePerToken} ${reserveLabel}` : ""; @@ -74,7 +74,7 @@ export async function generateMetadata({ openGraph: { title: sl.title, description, - images: [{ url: ogImageUrl, width: 1200, height: 800 }], + images: [{ url: ogImageUrl, width: 1200, height: 630 }], }, other: { "fc:miniapp": fcEmbed, @@ -169,7 +169,7 @@ function StoryHeader({ storyline: Storyline; priceInfo: TokenPriceInfo | null; }) { - const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT"; + const reserveLabel = RESERVE_LABEL; return (