Skip to main content

Bulletin Board — Permissions & Reporting

Overview

This page describes the bulletin board's control layer, which consists of three interrelated parts:

  1. Capability — what this user is allowed to do: a member who can write, a guest who can only read, or someone who has been blocked.
  2. Content reporting — the channel through which users flag inappropriate posts or comments.
  3. Post-owner menu — editing, deleting, and toggling comments on your own posts.

One thing to understand up front: the API that returns board data says nothing about who the caller is or what they may do. Enforcement lives entirely on the server, so the web client guesses optimistically and then learns from the errors it receives. This approach means ordinary users never wait on an extra permission check before the compose box appears.

Business Flow

Capability

  1. If board settings allow guest writing, everyone who reaches the page is treated as a member who can post right away.
  2. If not, capability starts as "unknown", yet the compose box is still shown (optimistically).
  3. When the user tries to post and is refused with a 403, capability is updated from the returned error code — a write refusal downgrades them to guest, while a block code marks them as blocked.
  4. That outcome is remembered in sessionStorage per OA, so a blocked user sees the compose box only once per session rather than on every scroll.
  5. Reading from sessionStorage goes through useSyncExternalStore with a custom listener set, because the browser's storage event does not fire in the tab that wrote the value.

Content Reporting

  1. The report button appears on posts and comments that are not the user's own, and is disabled when board settings forbid user reports or when the user is blocked.
  2. The user picks a reason from a fixed set: spam, harassment, inappropriate content, misinformation, and other. Any value outside this set is rejected by the server.
  3. Optional details can be added, up to 500 characters, before submitting.
  4. On success the server returns a 201 with an empty body, so there is no report ID to display or reference afterwards.
  5. Reporting the same content twice returns a 409, and the user is told they have already reported it.

Post-Owner Menu

Shown when the post belongs to the current user. It offers three actions:

ActionEndpointNotes
EditPUT /bulletin/{hash}/posts/{id}Category changes are not supported, so the edit form has no category control. The title remains required. For images: omitting them keeps the existing set, while sending an empty list removes them all.
DeleteDELETE /bulletin/{hash}/posts/{id}Returns a 204.
Toggle commentsPUT /bulletin/{hash}/posts/{id}/comments-settingThe desired state must always be sent in the body.

Reactions

  • Tapping the same emoji again removes the reaction. The server interprets it as: no existing reaction means insert, a different emoji means update, and the same emoji means delete.
  • A single request returns the full count map for every emoji, not just the one tapped.
  • The client seeds initial state from the post's reaction data plus the user's own reaction, so the first toggle behaves correctly.
  • Only emoji from the board's configured set can be selected.

Error Normalization

The error helper handles the three shapes that actually occur in practice: axios errors, an empty envelope with no body, and anything else unexpected. It knows the API envelope carries a status, a message, and an error name — and that there is no separate field for an error code. Board-specific codes therefore arrive inside the message, which may be either a single string or a list.

The helper also classifies errors that should stay "silent" — specifically the blocked case, which shows no toast at all, since repeating the notice helps nobody and needlessly confirms the user's status.

Key Screens & Components

All of these live under src/app/[hash]/bulletin/:

  • Capability hook (hooks/useBoardCapability.ts) — exposes the current capability and a function to record a refusal.
  • Report sheet (components/ReportSheet.tsx) — a drawer for picking a reason and entering details, with the reason list held as a constant.
  • Owner menu (components/OwnerMenu.tsx) — a dropdown with the three actions.
  • Reaction bar (components/ReactionBar.tsx)
  • Error normalizer (lib/errors.ts) — includes Thai message tables keyed both by error code and by HTTP status.
  • Category helper (lib/categories.ts) — filters down to categories the user may post in.

The board's error codes are collected as constants in src/service/types/bulletin.type.ts, alongside the maximum title length.

Dependencies

  • Ant Design — uses Drawer, Dropdown, Radio, the multiline text input, and the message/modal system via App.useApp().
  • React 19's useSyncExternalStore — reads sessionStorage correctly so what is displayed always matches what is stored.
  • Directly coupled to bulletin-board and bulletin-post-detail, since these components are passed into the post and comment cards as part of their composition.
  • A test suite covers error normalization, the reaction bar, and per-category posting permissions.

Backend Details (Client API)

The five capabilities the server actually enforces

The web client can afford to guess optimistically because the backend checks five capabilities on every endpoint, derived from an access context computed once per request:

CapabilityRule the server applies
View boardThe user type must be in view_access, otherwise 403 BULLETIN_VIEW_FORBIDDEN
Write postsIn write_access (otherwise BULLETIN_WRITE_FORBIDDEN) and no block whose scope is anything other than comment → otherwise BULLETIN_BLOCKED
CommentIn write_access and no block at all — a block in either scope forbids commenting
ReactSame rule as writing posts — someone muted for comments only can still react
Reportallow_user_reports must be on (otherwise BULLETIN_REPORTS_DISABLED), then the same rule as writing posts
  • Block evaluation is deny-by-default: a misspelled or zero-value scope counts as a full block rather than being waved through.
  • A block whose expiry date is in the past is inert — the repository already filters it out, and the capability layer rechecks as a second belt.
  • The whole error-code set is returned inside the message, not as a separate field, which is exactly the behaviour the web side describes.

Content reporting

POST /api/bulletin/:hash/reports — rate limited to 5 per 60 seconds

  • reason must come from a closed allow-list (spam, harassment, inappropriate, misinformation, other), otherwise 400 BULLETIN_INVALID_REASON. This is not merely data hygiene: the column is VARCHAR(50) NOT NULL, and the CMS moderation queue switches on these codes. An unknown reason would either make the admin UI render an attacker's raw string or fall straight through the switch.
  • Detail longer than 500 runes → 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 onto an admin screen.
  • The insert runs in a transaction with an audit entry and enforces one report per (target, reporter), forever, via a unique index. A duplicate becomes 409 "already reported", translated from the index's own error — not a check-then-insert, which would race against itself on a double tap.
  • It answers 201 with no body, which is why the web side has no report ID to show.

Reactions

PUT /api/bulletin/:hash/reactions — rate limited to 30 per 60 seconds

  • The emoji must be in the board's own reaction_emojis set, otherwise 400 BULLETIN_INVALID_EMOJI. Without this check the setting would be decorative: any string would be stored and then rendered on everyone's reaction bar, and a string longer than VARCHAR(16) would surface as a Postgres error — a 500 where a 400 belongs. The membership check covers both the empty-string and the too-long case at once.
  • The toggle runs in one transaction anchored on the unique index (target type, target id, user).
    • Same emoji again → DELETE (toggle off). A DELETE that matches no row, because another request removed it first, is a no-op rather than an error.
    • Anything else → an upsert in a single statement (insert-or-replace) rather than read-then-write, because locking a row that does not yet exist locks nothing. Two concurrent first-time reactions must not become a duplicate-key 500.
    • The audit entry is written in the same transaction.
  • It returns the post-mutation count map so the client can update the bar immediately.
  • Reactions are the only table in this domain that is hard-deleted; everything else is soft-deleted.

verifyTarget — why visibility, not just existence, is checked

Both reacting and reporting call verifyTarget before touching data, and that gate checks visibility rather than mere existence:

  • Skipping that predicate would turn these two endpoints into an oracle revealing both "this exists" and "this much engagement" for content the category visibility rules deliberately hide. A 200 with an emoji breakdown confirms a post that every other route answers 404 for.
  • Reporting is worse still, because the report_count trigger fires — an outsider could inflate the moderation numbers of content they should not know exists.
  • A comment target is checked at two levels: the comment's own status and the parent post's visibility (a comment on a deleted or hidden post has to be unreachable here too). The parent's 404 has its message rewritten to "comment not found" so the two cases are indistinguishable.
  • Any target type other than post or comment → 400 "invalid target type"; every other failure is the same 404.

The post-owner menu — server-side rules

  • Creating a post (POST /api/bulletin/:hash/posts, rate limited 5/60s) validates the title in an order that is part of the contract: trim first, then check for emptiness (BULLETIN_EMPTY_TITLE), then check length (BULLETIN_TITLE_TOO_LONG, capped at 200 counted in runes, because Postgres VARCHAR(200) counts characters and a 200-character Thai title must pass). The value stored is the trimmed one, so a title of 300 spaces is "empty", not "too long".
  • If a category is specified, two checks run in this order: first, that the category really belongs to the caller's board (the foreign key only proves it exists somewhere) — not found → 400 "invalid category", not 404; then whether the category allows LINE users to post — if not → 403 BULLETIN_CATEGORY_ADMIN_ONLY. This order makes another board's category answer "invalid category" rather than "admin only", which would confirm that category id exists.
  • Editing (PUT) gets no exemption from the title rules (an edit that empties the title would leave an unnamed thread in a title-led list), and a published post on a board requiring approval returns to pending after an edit. The edit input deliberately has no category field, because the repository has no column to write it to and a field that is accepted then silently discarded is worse than none. That is also why editing need not recheck category permissions: an edit cannot move a post into an admin-only category.
  • Deleting is always a soft delete (setting the status and deletion date), never a SQL DELETE.
  • Every mutation runs in one transaction together with its audit row, so the audit trail can never have holes. If any step fails, the whole thing rolls back.

Image commitment — the hidden security gate

Images attached to posts and comments are "committed" from temp/ to a permanent path shaped as bulletin/post-or-comment/ownerID/index.ext. This is not just tidiness — it is proof that the caller actually uploaded that object.

  • It runs in two deliberate phases: phase 1 normalizes the keys and verifies every one of them exists; only then does phase 2 copy. A request with one broken reference writes nothing at all.
  • The existence check is the real security step — the only thing preventing a caller from referencing another tenant's object or a key that was never uploaded. Failure → 400 BULLETIN_INVALID_IMAGE.
  • Only two key forms are accepted: a raw key under temp/, and the public URL the upload endpoint returned. Anything else (a different prefix, path traversal, arbitrary text) is rejected.
  • No storage configuration means fail closed — every image is rejected rather than letting temp keys through into storage, which was the original defect that made all images 404 once the temp/ prefix was swept.
  • The index in the filename is the position in the submitted array, so re-committing overwrites the caller's own objects instead of accumulating garbage.
  • The object store interface has no delete method, on purpose: objects in temp/ are never removed here, because deleting them and then rolling back the transaction would leave both the source gone and nothing pointing at the copy. Sweeping temp/ is a separate operations job.