Skip to content
Open
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
23 changes: 23 additions & 0 deletions .changeset/comment-reactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"emdash": minor
---

Add comment reactions (Tier 1 of the best-in-class comments RFC).

Visitors can now react to approved comments (positive-only "like" by default,
extensible to other reaction types). Reactions are stored first-party in a new
`_emdash_comment_reactions` table, deduped per voter via a salted IP hash (the
same privacy primitive as comment `ip_hash`), and exposed through a public,
honeypot- and rate-limited endpoint at
`POST/GET /_emdash/api/comments/:collection/:contentId/reactions`.

The `<Comments>` component gains two opt-in props:

- `reactions` — render a like button per comment and attach live counts.
- `sort="best"` — order top-level comments by a Reddit-style Wilson score
lower bound (`sort="oldest"`, the previous behavior, remains the default).

Posting is progressively enhanced (a tiny inline script, no framework island)
and emitted only when `reactions` is enabled, so pages that don't use reactions
ship zero additional JavaScript. Fully additive and backward-compatible: a new
table, a new route, and new optional props with behavior-preserving defaults.
160 changes: 160 additions & 0 deletions packages/core/src/api/handlers/comment-reactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Comment reaction handlers (Tier 1 of the best-in-class comments RFC).
*
* Business logic for toggling reactions and reading aggregate counts. Route
* files stay thin wrappers; these return `ApiResult<T>`.
*/

import type { Kysely } from "kysely";

import {
CommentReactionRepository,
type ReactionCounts,
} from "#db/repositories/comment-reaction.js";
import type { Database } from "#db/types.js";

import type { ApiResult } from "../types.js";

/** Max reactions a single voter may register per window before throttling. */
const REACTION_RATE_LIMIT = 30;
const REACTION_RATE_WINDOW_MINUTES = 10;

/**
* Reactions the system accepts. Positive-only for now (matches the shipped
* widget); kept as an allowlist so a voter can't spam arbitrary reaction
* strings and bloat a comment's count map. Extend (or make configurable) as
* the UI grows.
*/
const ALLOWED_REACTIONS: ReadonlySet<string> = new Set(["like"]);

export interface ReactionToggleResult {
commentId: string;
reaction: string;
/** true if the reaction was added, false if an existing one was removed */
reacted: boolean;
/** updated counts for this comment after the toggle */
counts: ReactionCounts;
}

export interface ReactionCountsResult {
/** comment id -> reaction counts */
reactions: Record<string, ReactionCounts>;
/** comment id -> reactions the current voter has active (omitted if anonymous) */
viewer?: Record<string, string[]>;
}

/**
* Toggle a reaction for a voter on an approved comment belonging to the given
* content item. Rate-limited per voter.
*/
export async function handleReactionToggle(
db: Kysely<Database>,
params: {
collection: string;
contentId: string;
commentId: string;
reaction: string;
voterHash: string;
},
): Promise<ApiResult<ReactionToggleResult>> {
try {
const { collection, contentId, commentId, reaction, voterHash } = params;

if (!ALLOWED_REACTIONS.has(reaction)) {
return {
success: false,
error: { code: "VALIDATION_ERROR", message: "Unsupported reaction" },
};
}

// The comment must exist, be approved, and belong to this content item.
const comment = await db
.selectFrom("_emdash_comments")
.select(["id", "status"])
.where("id", "=", commentId)
.where("collection", "=", collection)
.where("content_id", "=", contentId)
.executeTakeFirst();

if (!comment) {
return { success: false, error: { code: "NOT_FOUND", message: "Comment not found" } };
}
if (comment.status !== "approved") {
return {
success: false,
error: { code: "COMMENT_NOT_APPROVED", message: "Cannot react to this comment" },
};
}

const repo = new CommentReactionRepository(db);

const recent = await repo.countRecentByVoter(voterHash, REACTION_RATE_WINDOW_MINUTES);
if (recent >= REACTION_RATE_LIMIT) {
return {
success: false,
error: { code: "RATE_LIMITED", message: "Too many reactions. Please try again later." },
};
}

const { reacted } = await repo.toggle({ commentId, reaction, voterHash });
const countsMap = await repo.countsForComments([commentId]);

return {
success: true,
data: { commentId, reaction, reacted, counts: countsMap.get(commentId) ?? {} },
};
} catch {
return {
success: false,
error: { code: "REACTION_TOGGLE_ERROR", message: "Failed to toggle reaction" },
};
}
}

/**
* Read aggregate reaction counts for every approved comment on a content item,
* plus (optionally) which reactions the current voter has active.
*/
export async function handleReactionCounts(
db: Kysely<Database>,
collection: string,
contentId: string,
voterHash?: string,
): Promise<ApiResult<ReactionCountsResult>> {
try {
const comments = await db
.selectFrom("_emdash_comments")
.select("id")
.where("collection", "=", collection)
.where("content_id", "=", contentId)
.where("status", "=", "approved")
.execute();

const ids = comments.map((c) => c.id);
const repo = new CommentReactionRepository(db);

const countsMap = await repo.countsForComments(ids);
const reactions: Record<string, ReactionCounts> = {};
for (const [id, counts] of countsMap) {
reactions[id] = counts;
}

const data: ReactionCountsResult = { reactions };

if (voterHash) {
const viewerMap = await repo.viewerReactions(ids, voterHash);
const viewer: Record<string, string[]> = {};
for (const [id, list] of viewerMap) {
viewer[id] = list;
}
data.viewer = viewer;
}

return { success: true, data };
} catch {
return {
success: false,
error: { code: "REACTION_COUNTS_ERROR", message: "Failed to read reactions" },
};
}
}
10 changes: 10 additions & 0 deletions packages/core/src/api/schemas/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export const createCommentBody = z
})
.meta({ id: "CreateCommentBody" });

export const createReactionBody = z
.object({
commentId: z.string().min(1),
/** Reaction name. Positive-only ("like") by default; extensible. */
reaction: z.string().min(1).max(20).default("like"),
/** Honeypot field — hidden in the form, filled only by bots */
website_url: z.string().optional(),
})
.meta({ id: "CreateReactionBody" });

export const commentStatusBody = z
.object({
status: z.enum(["approved", "pending", "spam", "trash"]),
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,11 @@ export function injectCoreRoutes(
entrypoint: resolveRoute("api/comments/[collection]/[contentId]/index.ts"),
});

injectRoute({
pattern: "/_emdash/api/comments/[collection]/[contentId]/reactions",
entrypoint: resolveRoute("api/comments/[collection]/[contentId]/reactions.ts"),
});

// Comment routes (admin)
injectRoute({
pattern: "/_emdash/api/admin/comments",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Public comment reaction endpoints (Tier 1 of the best-in-class comments RFC).
*
* GET /_emdash/api/comments/:collection/:contentId/reactions
* - Aggregate reaction counts for the content's approved comments, plus the
* current visitor's active reactions.
* POST /_emdash/api/comments/:collection/:contentId/reactions
* - Toggle a reaction on a comment. Public, honeypot- and rate-limit-gated.
*
* Inherits the `/_emdash/api/comments/` public prefix (no auth); the POST still
* requires the `X-EmDash-Request: 1` CSRF header like the comment POST.
*/

import type { APIRoute } from "astro";

import { apiError, apiSuccess, handleError, requireDb, unwrapResult } from "#api/error.js";
import { handleReactionCounts, handleReactionToggle } from "#api/handlers/comment-reactions.js";
import { hashIp } from "#api/handlers/comments.js";
import { isParseError, parseBody } from "#api/parse.js";
import { createReactionBody } from "#api/schemas.js";
import { resolveSecretsCached } from "#config/secrets.js";
import { extractRequestMeta } from "#plugins/request-meta.js";

export const prerender = false;

export const GET: APIRoute = async ({ params, request, locals }) => {
const { emdash } = locals;
const { collection, contentId } = params;

if (!collection || !contentId) {
return apiError("VALIDATION_ERROR", "Collection and content ID required", 400);
}

const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;

try {
// Salted voter hash from request IP (same primitive as comment ip_hash).
// Behind Cloudflare (CF-Connecting-IP) or a configured trusted proxy this
// is per-visitor. Without a trusted IP it collapses to a shared "unknown"
// bucket, so reaction dedup degrades for those visitors — a real
// per-visitor token is Tier 2 (visitor identity). Operators should set
// trustedProxyHeaders; see the comment ingest route for the same note.
const meta = extractRequestMeta(request, emdash.config);
let voterHash = "unknown";
if (meta.ip) {
const { ipSalt } = await resolveSecretsCached(emdash.db);
voterHash = await hashIp(meta.ip, ipSalt);
}

const result = await handleReactionCounts(emdash.db, collection, contentId, voterHash);
return unwrapResult(result);
} catch (error) {
return handleError(error, "Failed to read reactions", "REACTION_COUNTS_ERROR");
}
};

export const POST: APIRoute = async ({ params, request, locals }) => {
const { emdash } = locals;
const { collection, contentId } = params;

if (!collection || !contentId) {
return apiError("VALIDATION_ERROR", "Collection and content ID required", 400);
}

const dbErr = requireDb(emdash?.db);
if (dbErr) return dbErr;

try {
const body = await parseBody(request, createReactionBody);
if (isParseError(body)) return body;

// Anti-spam: honeypot — hidden field filled only by bots. Silently accept.
if (body.website_url) {
return apiSuccess({ reacted: false, counts: {} });
}

const meta = extractRequestMeta(request, emdash.config);
let voterHash = "unknown";
if (meta.ip) {
const { ipSalt } = await resolveSecretsCached(emdash.db);
voterHash = await hashIp(meta.ip, ipSalt);
}

const result = await handleReactionToggle(emdash.db, {
collection,
contentId,
commentId: body.commentId,
reaction: body.reaction,
voterHash,
});

return unwrapResult(result, 200);
} catch (error) {
return handleError(error, "Failed to toggle reaction", "REACTION_TOGGLE_ERROR");
}
};
Loading
Loading