feat : comment feature
CI / Verify (push) Successful in 2m30s
CI / Build immutable images and deploy (push) Successful in 3m9s

This commit is contained in:
2026-10-08 01:40:08 +07:00 Unverified
parent 8c8815cda5
commit 5812d99715
65 changed files with 27013 additions and 10 deletions
+13
View File
@@ -0,0 +1,13 @@
import { listComments, requireCommentAdmin, requireCommentViewer } from "@/lib/comments/repository";
import { errorResponse } from "@/lib/security/http";
export async function GET(request: Request) {
try {
const viewer = await requireCommentViewer();
requireCommentAdmin(viewer);
const query = new URL(request.url).searchParams;
return Response.json(await listComments({ viewer, inbox: true, target: query.get("target") || undefined,
status: query.get("status") || undefined, unanswered: query.get("unanswered") === "true", cursor: query.get("cursor") }),
{ headers: { "Cache-Control": "private, no-store" } });
} catch (cause) { return errorResponse(cause); }
}
+21
View File
@@ -0,0 +1,21 @@
import * as z from "zod";
import { requireCommentViewer } from "@/lib/comments/repository";
import { markCommentsRead, unreadCommentCounts } from "@/lib/comments/unread";
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
export async function GET() {
try { return Response.json(await unreadCommentCounts(await requireCommentViewer()), { headers: { "Cache-Control": "private, no-store" } }); }
catch (cause) { return errorResponse(cause); }
}
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
await limitRequest("comment-read", viewer.id, 120);
const body = z.object({ target: z.string().max(80) }).safeParse(await readJson(request, 1024));
if (!body.success) throw new HttpError(400, "invalid-target");
await markCommentsRead(viewer, body.data.target);
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
+41
View File
@@ -0,0 +1,41 @@
import { commentHistory, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository";
import { heartSchema, moderationSchema, reactionSchema } from "@/lib/comments/validation";
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
type Context = { params: Promise<{ id: string; action: string }> };
export async function GET(request: Request, context: Context) {
try {
const { id, action } = await context.params;
const viewer = await getCommentViewer();
const cursor = new URL(request.url).searchParams.get("cursor");
const result = action === "history" ? await commentHistory(id, viewer, cursor)
: action === "replies" ? await listComments({ viewer, rootId: id, cursor, target: new URL(request.url).searchParams.get("target") ?? undefined })
: null;
if (!result) throw new HttpError(404, "not-found");
return Response.json(result, { headers: { "Cache-Control": "private, no-store" } });
} catch (cause) { return errorResponse(cause); }
}
export async function PUT(request: Request, context: Context) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
await limitRequest("comment-mutate", viewer.id, 60);
const { id, action } = await context.params;
const body = await readJson(request);
if (action === "reaction") {
const value = reactionSchema.safeParse(body);
if (!value.success) throw new HttpError(400, "invalid-reaction");
await mutateComment(id, viewer, "reaction", value.data.value);
} else if (action === "moderation") {
const value = moderationSchema.safeParse(body);
if (!value.success) throw new HttpError(400, "invalid-moderation");
await mutateComment(id, viewer, "moderation", value.data.hidden);
} else if (action === "heart") {
const value = heartSchema.safeParse(body);
if (!value.success) throw new HttpError(400, "invalid-heart");
await mutateComment(id, viewer, "heart", value.data.hearted);
} else throw new HttpError(404, "not-found");
return Response.json({ ok: true });
} catch (cause) { return errorResponse(cause); }
}
+32
View File
@@ -0,0 +1,32 @@
import { authorizeComment, commentId, getCommentViewer, listComments, mutateComment, requireCommentViewer } from "@/lib/comments/repository";
import { publishComment } from "@/lib/comments/publish";
import { errorResponse, requireSameOrigin } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, context: Context) {
try {
const viewer = await getCommentViewer();
const id = commentId((await context.params).id);
const authorized = await authorizeComment(id, viewer);
const result = await listComments({ viewer, target: authorized.destination.target, id });
return Response.json({ item: result.items[0], viewer }, { headers: { "Cache-Control": "private, no-store" } });
} catch (cause) { return errorResponse(cause); }
}
export async function PATCH(request: Request, context: Context) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
await limitRequest("comment-publish", viewer.id, 20);
return Response.json(await publishComment(request, viewer, { id: commentId((await context.params).id) }));
} catch (cause) { return errorResponse(cause); }
}
export async function DELETE(request: Request, context: Context) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
await limitRequest("comment-mutate", viewer.id, 60);
await mutateComment((await context.params).id, viewer, "delete");
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
+42
View File
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { HttpError } from "@/lib/security/http";
const mocks = vi.hoisted(() => ({ viewer: vi.fn(), target: vi.fn(), stream: vi.fn(), limit: vi.fn() }));
vi.mock("@/lib/comments/repository", () => ({
getCommentViewer: mocks.viewer, getCommentTarget: mocks.target,
requireCommentAdmin: (viewer: { admin: boolean } | null) => { if (!viewer?.admin) throw new HttpError(403, "forbidden"); },
}));
vi.mock("@/lib/events/redis-stream", () => ({ createRedisNamedEventResponse: mocks.stream }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: mocks.limit, trustedClientAddress: () => "127.0.0.1" }));
import { GET } from "./route";
describe("comment event authorization", () => {
beforeEach(() => {
vi.clearAllMocks(); mocks.viewer.mockResolvedValue(null); mocks.target.mockResolvedValue({ target: "stygian:123" });
mocks.stream.mockResolvedValue(new Response("event: changed\ndata: changed\n\n", { headers: { "Content-Type": "text/event-stream" } }));
});
it("allows anonymous subscribers for a readable target", async () => {
const request = new Request("https://guide.test/api/comments/events?target=stygian:123");
expect((await GET(request)).status).toBe(200);
expect(mocks.target).toHaveBeenCalledWith("stygian:123", null);
expect(mocks.stream).toHaveBeenCalledWith("comments:stygian:123", "changed", request.signal);
});
it("does not subscribe to an inaccessible guide", async () => {
mocks.target.mockRejectedValue(new HttpError(404, "guide-not-found"));
expect((await GET(new Request("https://guide.test/api/comments/events?target=guide:private"))).status).toBe(404);
expect(mocks.stream).not.toHaveBeenCalled();
});
it("rejects anonymous admin inbox subscriptions", async () => {
expect((await GET(new Request("https://guide.test/api/comments/events?scope=admin"))).status).toBe(403);
expect(mocks.stream).not.toHaveBeenCalled();
});
it("allows verified admin inbox subscriptions", async () => {
mocks.viewer.mockResolvedValue({ id: "admin", admin: true });
const request = new Request("https://guide.test/api/comments/events?scope=admin");
expect((await GET(request)).status).toBe(200);
expect(mocks.stream).toHaveBeenCalledWith("comments:admin", "changed", request.signal);
});
it("requires a target for public subscriptions", async () => {
expect((await GET(new Request("https://guide.test/api/comments/events"))).status).toBe(400);
expect(mocks.stream).not.toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { createRedisNamedEventResponse } from "@/lib/events/redis-stream";
import { getCommentTarget, getCommentViewer, requireCommentAdmin } from "@/lib/comments/repository";
import { errorResponse, HttpError } from "@/lib/security/http";
import { limitRequest, trustedClientAddress } from "@/lib/security/rate-limit";
export async function GET(request: Request) {
try {
await limitRequest("stream-open", trustedClientAddress(request.headers), 60);
const query = new URL(request.url).searchParams;
const viewer = await getCommentViewer();
let topic: string;
if (query.get("scope") === "admin") {
requireCommentAdmin(viewer);
topic = "comments:admin";
} else {
const target = query.get("target");
if (!target) throw new HttpError(400, "target-required");
const destination = await getCommentTarget(target, viewer);
topic = `comments:${destination.target}`;
}
// Events contain no comment text, images, or author information.
// Every refresh rechecks current guide visibility and viewer permissions.
return await createRedisNamedEventResponse(topic, "changed", request.signal);
} catch (cause) { return errorResponse(cause); }
}
+13
View File
@@ -0,0 +1,13 @@
import { commentImage, getCommentViewer } from "@/lib/comments/repository";
import { publicMediaUrl } from "@/lib/media/storage";
import { errorResponse } from "@/lib/security/http";
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
try {
const image = await commentImage((await context.params).id, await getCommentViewer());
// Keep old attachment links working without proxying image bytes through the app.
return new Response(null, { status: 307, headers: {
Location: publicMediaUrl(image.objectKey), "Cache-Control": "private, no-store",
} });
} catch (cause) { return errorResponse(cause); }
}
@@ -0,0 +1,45 @@
import { and, eq } from "drizzle-orm";
import * as z from "zod";
import { getDb } from "@/db";
import { commentPushSubscriptions } from "@/db/schema";
import { pushPublicKey, validPushEndpoint } from "@/lib/commission/push";
import { requireCommentViewer } from "@/lib/comments/repository";
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
const subscriptionSchema = z.object({
endpoint: z.string().max(2048),
keys: z.object({ p256dh: z.string().regex(/^[A-Za-z0-9_-]{50,200}$/), auth: z.string().regex(/^[A-Za-z0-9_-]{10,100}$/) }),
});
export async function GET() {
try {
await requireCommentViewer();
const publicKey = pushPublicKey();
if (!publicKey) throw new HttpError(503, "push-not-configured");
return Response.json({ publicKey }, { headers: { "Cache-Control": "private, no-store" } });
} catch (cause) { return errorResponse(cause); }
}
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
if (!pushPublicKey()) throw new HttpError(503, "push-not-configured");
await limitRequest("comment-push-subscription", viewer.id, 60);
const parsed = subscriptionSchema.safeParse(await readJson(request, 4096));
if (!parsed.success || !validPushEndpoint(parsed.data.endpoint)) throw new HttpError(400, "invalid-push-subscription");
const { endpoint, keys } = parsed.data;
await getDb().insert(commentPushSubscriptions).values({ endpoint, userId: viewer.id, ...keys })
.onConflictDoUpdate({ target: commentPushSubscriptions.endpoint, set: { userId: viewer.id, ...keys } });
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
export async function DELETE(request: Request) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
const parsed = z.object({ endpoint: z.string().max(2048) }).safeParse(await readJson(request, 4096));
if (!parsed.success) throw new HttpError(400, "invalid-push-subscription");
await getDb().delete(commentPushSubscriptions).where(and(eq(commentPushSubscriptions.endpoint, parsed.data.endpoint), eq(commentPushSubscriptions.userId, viewer.id)));
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
}
+27
View File
@@ -0,0 +1,27 @@
import { after } from "next/server";
import { sendCommentReplyPush } from "@/lib/comments/push";
import { getCommentViewer, listComments, requireCommentViewer } from "@/lib/comments/repository";
import { publishComment } from "@/lib/comments/publish";
import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http";
import { limitRequest } from "@/lib/security/rate-limit";
export async function GET(request: Request) {
try {
const query = new URL(request.url).searchParams;
const target = query.get("target");
if (!target) throw new HttpError(400, "target-required");
return Response.json(await listComments({ viewer: await getCommentViewer(), target, cursor: query.get("cursor"), sort: query.get("sort") ?? undefined }), { headers: { "Cache-Control": "private, no-store" } });
} catch (cause) { return errorResponse(cause); }
}
export async function POST(request: Request) {
try {
requireSameOrigin(request);
const viewer = await requireCommentViewer();
await limitRequest("comment-publish", viewer.id, 20);
const target = new URL(request.url).searchParams.get("target");
if (!target) throw new HttpError(400, "target-required");
const result = await publishComment(request, viewer, { target });
after(async () => { await sendCommentReplyPush(result.id).catch(() => console.error("Comment reply notification failed")); });
return Response.json(result, { status: 201 });
} catch (cause) { return errorResponse(cause); }
}