Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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"];
145 changes: 145 additions & 0 deletions src/app/api/comments/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
3 changes: 2 additions & 1 deletion src/app/api/index/plot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions supabase/migrations/00008_plot_title_and_comments.sql
Original file line number Diff line number Diff line change
@@ -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);
Loading