diff --git a/lib/supabase.ts b/lib/supabase.ts index 16a8f34a..bbd58282 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -131,6 +131,7 @@ export interface Database { storyline_id: number; plot_index: number; writer_address: string; + title: string; content: string | null; content_cid: string; content_hash: string; @@ -145,6 +146,7 @@ export interface Database { storyline_id: number; plot_index: number; writer_address: string; + title?: string; content?: string | null; content_cid: string; content_hash: string; @@ -159,6 +161,7 @@ export interface Database { storyline_id?: number; plot_index?: number; writer_address?: string; + title?: string; content?: string | null; content_cid?: string; content_hash?: string; @@ -169,6 +172,35 @@ export interface Database { indexed_at?: string; }; }; + comments: { + Row: { + id: number; + storyline_id: number; + plot_index: number; + commenter_address: string; + content: string; + created_at: string; + hidden: boolean; + }; + Insert: { + id?: never; + storyline_id: number; + plot_index: number; + commenter_address: string; + content: string; + created_at?: string; + hidden?: boolean; + }; + Update: { + id?: never; + storyline_id?: number; + plot_index?: number; + commenter_address?: string; + content?: string; + created_at?: string; + hidden?: boolean; + }; + }; donations: { Row: { id: number; @@ -239,3 +271,4 @@ export type Storyline = Database["public"]["Tables"]["storylines"]["Row"]; export type Plot = Database["public"]["Tables"]["plots"]["Row"]; export type Donation = Database["public"]["Tables"]["donations"]["Row"]; export type Rating = Database["public"]["Tables"]["ratings"]["Row"]; +export type Comment = Database["public"]["Tables"]["comments"]["Row"]; diff --git a/src/app/api/comments/route.ts b/src/app/api/comments/route.ts new file mode 100644 index 00000000..1dae1157 --- /dev/null +++ b/src/app/api/comments/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { type Address } from "viem"; +import { publicClient } from "../../../../lib/rpc"; +import { createServerClient, supabase } from "../../../../lib/supabase"; + +const MAX_COMMENT_LENGTH = 1000; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +function error(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +// --------------------------------------------------------------------------- +// GET /api/comments?storylineId=N&plotIndex=M&offset=0 +// --------------------------------------------------------------------------- + +export async function GET(req: NextRequest) { + const storylineId = req.nextUrl.searchParams.get("storylineId"); + const plotIndex = req.nextUrl.searchParams.get("plotIndex"); + + if (!storylineId || !plotIndex) return error("Missing storylineId or plotIndex"); + + const db = supabase; + if (!db) return error("Supabase not configured", 500); + + const sid = Number(storylineId); + const pidx = Number(plotIndex); + if (isNaN(sid) || isNaN(pidx)) return error("Invalid storylineId or plotIndex"); + + const limit = Math.min(Math.max(Number(req.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT), 1), MAX_LIMIT); + const page = Math.max(Number(req.nextUrl.searchParams.get("page") ?? 1), 1); + const offset = (page - 1) * limit; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error: dbError } = await (db.from("comments") as any) + .select("*") + .eq("storyline_id", sid) + .eq("plot_index", pidx) + .eq("hidden", false) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (dbError) return error(`Database error: ${dbError.message}`, 500); + + // Get total count for pagination + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { count } = await (db.from("comments") as any) + .select("id", { count: "exact", head: true }) + .eq("storyline_id", sid) + .eq("plot_index", pidx) + .eq("hidden", false); + + return NextResponse.json({ + comments: data ?? [], + total: count ?? 0, + page, + limit, + }); +} + +// --------------------------------------------------------------------------- +// POST /api/comments +// Body: { storylineId, plotIndex, content, address, signature, message } +// Rate limit: 1 comment per address per plot per minute +// --------------------------------------------------------------------------- + +interface CommentBody { + storylineId: number; + plotIndex: number; + content: string; + address: string; + signature: string; + message: string; +} + +export async function POST(req: NextRequest) { + let body: CommentBody; + try { + body = await req.json(); + } catch { + return error("Invalid JSON body"); + } + + const { storylineId, plotIndex, content, address, signature, message } = body; + + if (!storylineId || typeof storylineId !== "number") return error("Missing or invalid storylineId"); + if (typeof plotIndex !== "number" || plotIndex < 0) return error("Missing or invalid plotIndex"); + if (!content || typeof content !== "string") return error("Missing content"); + if (content.length > MAX_COMMENT_LENGTH) return error(`Comment must be ${MAX_COMMENT_LENGTH} characters or fewer`); + if (!address || !signature || !message) return error("Missing address, signature, or message"); + + // Validate signed message binds to this specific comment + const expectedMessage = `Comment on storyline ${storylineId} plot ${plotIndex}: ${content}`; + if (message !== expectedMessage) { + return error(`Signed message must be exactly: "${expectedMessage}"`); + } + + // Verify signature + const commenterAddress = address as Address; + try { + const valid = await publicClient.verifyMessage({ + address: commenterAddress, + message, + signature: signature as `0x${string}`, + }); + if (!valid) return error("Invalid signature"); + } catch { + return error("Failed to verify signature"); + } + + const serverClient = createServerClient(); + if (!serverClient) return error("Supabase not configured", 500); + + // Rate limit: max 1 comment per address per plot per minute + const oneMinuteAgo = new Date(Date.now() - 60 * 1000).toISOString(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: recent } = await (serverClient.from("comments") as any) + .select("id") + .eq("storyline_id", storylineId) + .eq("plot_index", plotIndex) + .eq("commenter_address", commenterAddress.toLowerCase()) + .gte("created_at", oneMinuteAgo) + .limit(1); + + if (recent && recent.length > 0) { + return NextResponse.json( + { error: "Please wait before commenting again" }, + { status: 429, headers: { "Retry-After": "60" } }, + ); + } + + // Insert comment + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: insertError } = await (serverClient.from("comments") as any).insert({ + storyline_id: storylineId, + plot_index: plotIndex, + commenter_address: commenterAddress.toLowerCase(), + content, + }); + + if (insertError) return error(`Database error: ${insertError.message}`, 500); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/index/plot/route.ts b/src/app/api/index/plot/route.ts index 7c7814c7..9acdb7a2 100644 --- a/src/app/api/index/plot/route.ts +++ b/src/app/api/index/plot/route.ts @@ -68,7 +68,7 @@ export async function POST(req: Request) { return error("Unexpected event type"); } - const { storylineId, plotIndex, writer, contentCID, contentHash } = + const { storylineId, plotIndex, writer, title, contentCID, contentHash } = decoded.args; // 4. Fetch content from IPFS (with fallback) @@ -113,6 +113,7 @@ export async function POST(req: Request) { storyline_id: Number(storylineId), plot_index: Number(plotIndex), writer_address: writer.toLowerCase(), + title: title || "", content, content_cid: contentCID, content_hash: contentHash as string, diff --git a/supabase/migrations/00008_plot_title_and_comments.sql b/supabase/migrations/00008_plot_title_and_comments.sql new file mode 100644 index 00000000..e3bf3971 --- /dev/null +++ b/supabase/migrations/00008_plot_title_and_comments.sql @@ -0,0 +1,23 @@ +-- Add title column to plots +ALTER TABLE plots ADD COLUMN title TEXT NOT NULL DEFAULT ''; + +-- Comments table +CREATE TABLE comments ( + id SERIAL PRIMARY KEY, + storyline_id INTEGER NOT NULL REFERENCES storylines(storyline_id), + plot_index INTEGER NOT NULL, + commenter_address TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + hidden BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX idx_comments_plot ON comments(storyline_id, plot_index); +CREATE INDEX idx_comments_commenter ON comments(commenter_address); + +-- RLS: public read (non-hidden), service-role insert +ALTER TABLE comments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Public read comments" + ON comments FOR SELECT + USING (hidden = false);