Skip to main content

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}

  1. resolveAccessCanReact(), which is equivalent to CanWrite(). A user muted only in the comment scope can still react.
  2. The emoji must belong to the board's own settings.reaction_emojis set; otherwise the request fails with 400 BULLETIN_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 the VARCHAR(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.
  3. verifyTarget runs (described below).
  4. A tap-to-toggle upsert executes in a single transaction, anchored on the unique index (target_type, target_id, line_user_id).
    • FindReactionForUpdate is 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.remove action. A DELETE that matches no row — because a concurrent request already removed it — is a no-op, not an error.
    • Otherwise → UpsertReaction as a single insert-or-replace statement, because FOR UPDATE on 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.set or reaction.remove) is written in the same transaction.
  5. The response carries the post-mutation emoji counts (ReactionCounts) so the client can refresh the reaction bar immediately.
  6. 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}

  1. resolveAccessCanReport(), which first requires the allow_user_reports setting to be on, then checks CanWrite().
  2. reason must appear in a closed allowlist: spam, harassment, inappropriate, misinformation, other. Anything else returns 400 BULLETIN_INVALID_REASON. The column is VARCHAR(50) NOT NULL and 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.
  3. A detail longer than 500 runes returns 400 BULLETIN_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.
  4. verifyTarget runs.
  5. The insert happens in a transaction alongside the report.create audit entry, allowing one report per (target, reporter) pair, permanently, enforced by a unique index. A duplicate returns 409 "already reported", translated from the repository's ErrDuplicateReport rather than from a racy check-then-insert.
  6. 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 with visiblePostForCaller.
  • 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 targetType returns 400 "invalid target type".
  • Every failure collapses into the same 404.

Key Files & Functions

RouteRate limitHandler
PUT /api/bulletin/:hash/reactions30/60sinternal/bulletin/handler.go(*Handler).SetReaction
POST /api/bulletin/:hash/reports5/60s(*Handler).CreateReport
  • internal/bulletin/service.goSetReaction, CreateReport, verifyTarget, commentVisibleToCaller, visiblePostForCaller
  • internal/bulletin/repository.goFindReactionForUpdate, UpsertReaction, DeleteReaction, ReactionCounts, ReactionStates, InsertReport, ErrDuplicateReport, InsertAudit
  • internal/bulletin/entity.goReaction, Report, ReportReasons, validReason, MaxReportDetailLength = 500, Settings.AllowsEmoji

Connections to Other Services

  • Tables bulletin.reaction (unique idx_bul_reaction_one), bulletin.report (unique idx_bul_report_once, report_count trigger), bulletin.post, bulletin.comment, bulletin.audit_log
  • The CMS moderation queue reads bulletin.report and switches on reason, so the allowlist must stay in sync on both sides
  • bulletin-access-control supplies CanReact and CanReport
  • ReactionStates is used by bulletin-board-feed to decorate a whole page
  • Corresponding client-web features: bulletin-moderation (reporting) and bulletin-board / bulletin-post-detail (the reaction bar)