Bulletin Board — Reactions & Reports
Overview
Two endpoints cover engagement and content moderation on the bulletin board: setting a reaction (tap to toggle) and reporting a post or comment into the CMS moderation queue.
What both share is the verifyTarget gate, which keeps these endpoints from becoming an oracle
for probing content the caller has no right to see.
Business Flow
Set a reaction — PUT /api/bulletin/:hash/reactions (rate limit 30/60s)
Body: {targetType, targetId, emoji}
resolveAccess→CanReact(), which is equivalent toCanWrite(). A user muted only in the comment scope can still react.- The emoji must belong to the board's own
settings.reaction_emojisset; otherwise the request fails with 400BULLETIN_INVALID_EMOJI. Without this check the setting would be decorative: any string of 1–16 characters would be stored and rendered on everyone's reaction bar, and anything longer would hit theVARCHAR(16)column as Postgres error 22001 — surfacing as a 500 instead of a 400. Checking set membership covers the empty-string and over-length cases in one step. verifyTargetruns (described below).- A tap-to-toggle upsert executes in a single transaction, anchored on the unique index
(target_type, target_id, line_user_id).FindReactionForUpdateis read only to detect the repeat-same-emoji case, the one case that requires knowing the previous value.- Same emoji → DELETE (toggle off) with the
reaction.removeaction. A DELETE that matches no row — because a concurrent request already removed it — is a no-op, not an error. - Otherwise →
UpsertReactionas a single insert-or-replace statement, becauseFOR UPDATEon a row that does not yet exist locks nothing. Two concurrent first-time reactions must not turn into a duplicate-key 500. - The audit entry (
reaction.setorreaction.remove) is written in the same transaction.
- The response carries the post-mutation emoji counts (
ReactionCounts) so the client can refresh the reaction bar immediately. - Reactions are the only table in this package using a hard delete; everything else is soft-deleted.
Report content — POST /api/bulletin/:hash/reports (rate limit 5/60s)
Body: {targetType, targetId, reason, detail}
resolveAccess→CanReport(), which first requires theallow_user_reportssetting to be on, then checksCanWrite().reasonmust appear in a closed allowlist:spam,harassment,inappropriate,misinformation,other. Anything else returns 400BULLETIN_INVALID_REASON. The column isVARCHAR(50) NOT NULLand the CMS moderation queue switches on these codes; an unrecognized reason would either render an attacker's raw text to a moderator or fall through the switch.- A
detaillonger than 500 runes returns 400BULLETIN_DETAIL_TOO_LONG. The column is TEXT, so the database imposes no bound — without a cap here, any logged-in user could write unbounded rows straight into the admin screen. verifyTargetruns.- The insert happens in a transaction alongside the
report.createaudit entry, allowing one report per (target, reporter) pair, permanently, enforced by a unique index. A duplicate returns 409"already reported", translated from the repository'sErrDuplicateReportrather than from a racy check-then-insert. - The endpoint responds 201 with no body.
verifyTarget — why visibility matters, not just existence
Every read path (GetPost, ListComments, CreateComment) goes through visiblePostForCaller and
returns 404 for posts hidden by a moderator, other people's pending posts, and posts in categories
whose audience excludes the caller. If these two endpoints skipped that predicate, they would become
an oracle revealing both the existence and the engagement volume of content that category visibility
rules deliberately hide — the category name itself may be confidential. A 200 with an emoji breakdown
confirms a post that returns 404 everywhere else. Reporting is worse still: the report_count
trigger fires, letting an outsider inflate the moderation counters on content they should not know
exists.
targetType = "post"is checked withvisiblePostForCaller.targetType = "comment"gets a two-layer check: the comment's own state (commentVisibleToCaller) and the parent post's visibility — a comment on a deleted or hidden post is unreachable everywhere else and must be unreachable here too. The parent post's 404 has its message rewritten to"comment not found"so the two cases are indistinguishable.- Any other
targetTypereturns 400"invalid target type". - Every failure collapses into the same 404.
Key Files & Functions
| Route | Rate limit | Handler |
|---|---|---|
PUT /api/bulletin/:hash/reactions | 30/60s | internal/bulletin/handler.go → (*Handler).SetReaction |
POST /api/bulletin/:hash/reports | 5/60s | (*Handler).CreateReport |
internal/bulletin/service.go—SetReaction,CreateReport,verifyTarget,commentVisibleToCaller,visiblePostForCallerinternal/bulletin/repository.go—FindReactionForUpdate,UpsertReaction,DeleteReaction,ReactionCounts,ReactionStates,InsertReport,ErrDuplicateReport,InsertAuditinternal/bulletin/entity.go—Reaction,Report,ReportReasons,validReason,MaxReportDetailLength = 500,Settings.AllowsEmoji
Connections to Other Services
- Tables
bulletin.reaction(uniqueidx_bul_reaction_one),bulletin.report(uniqueidx_bul_report_once,report_counttrigger),bulletin.post,bulletin.comment,bulletin.audit_log - The CMS moderation queue reads
bulletin.reportand switches onreason, so the allowlist must stay in sync on both sides - bulletin-access-control supplies
CanReactandCanReport ReactionStatesis used by bulletin-board-feed to decorate a whole page- Corresponding client-web features:
bulletin-moderation(reporting) andbulletin-board/bulletin-post-detail(the reaction bar)