Skip to main content

Bulletin Board — Creating, Editing, Deleting Posts, and Image Commitment

Overview

The complete write path for posts: create, edit, soft-delete, and toggle comments on one's own post. Every mutation runs inside a single transaction together with its audit log row, so the audit trail can never develop holes.

It also covers the image committer — the mechanism that moves images from the temp/ prefix created by the upload endpoint to their permanent path. This is both a filing operation and a security gate, since it proves the caller actually uploaded the object in question.

Business Flow

Creating a post — POST /api/bulletin/:hash/posts (rate limit 5/60s)

  1. Call resolveAccess, then check CanWrite().

  2. validateTitle — the order of its rules 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 — a 200-character Thai title must pass). The value stored is the trimmed one, so a title of 300 spaces is "empty" rather than "too long". The CMS uses the same two error codes.

  3. A body longer than max_body_length returns BULLETIN_BODY_TOO_LONG; more images than max_images_per_post returns BULLETIN_IMAGE_LIMIT.

  4. When a categoryId is supplied, two checks run in this order:

    • Ownership — the FK on post.category_id only proves the category exists somewhere, not that it belongs to the caller's board. A miss is therefore bad input: 400 with invalid category, not 404.
    • Post access — only then examine the category's post_access. Anything other than member returns 403 with BULLETIN_CATEGORY_ADMIN_ONLY.

    This ordering makes another board's category answer "invalid category" rather than "admin only", which would confirm that a category with that id exists.

  5. Determine status: require_post_approval yields pending, otherwise published. allowComments uses the supplied value when present, otherwise allow_comments_default — held as a pointer so "not supplied" is distinguishable from "supplied as false".

  6. Inside one transaction: insert the post → commit the images (necessarily after the insert, since the permanent key includes the post id) → call UpdatePostImages (kept separate from UpdatePost because the latter stamps edited_date, and a post with images should not be marked "edited" at birth) → insert the post.create audit row. If any step fails, everything rolls back, so there is never an orphaned post pointing at an object that was never copied.

  7. Return the PostView with status 201.

Editing a post — PUT /api/bulletin/:hash/posts/:id

  1. Check CanWrite(), then ownedPost — missing, belonging to another OA, or not the caller's own all return the same 404.
  2. Editing gets no exemption from the title rules, since editing a title down to nothing would leave an unnamed thread in a list that leads with titles.
  3. A post currently published on a board with require_post_approval set reverts to pending; other statuses are left as they are.
  4. UpdatePostInput deliberately has no categoryId field — the repository has no column to write it to, and a field silently accepted then discarded is worse than no field at all. This is exactly why UpdatePost need not re-check post_access: an edit cannot move a post into an admin-only category. A test serves as the tripwire.
  5. Commit images (only when images was supplied), then update and insert the post.edit audit row inside the same transaction, recording both the before and after states.

Deleting a post — DELETE /api/bulletin/:hash/posts/:id

Check CanWrite(), then ownedPost, then soft-delete by setting the status to deleted along with a deleted_datenever an SQL DELETE — and insert the post.delete audit row before returning 204.

Toggling comments — PUT /api/bulletin/:hash/posts/:id/comments-setting

Check CanWrite(), then ownedPost, then call SetAllowComments and insert the post.comments_toggle audit row before returning 204.

The image committer (storageCommitter.Commit)

  • The permanent layout is bulletin/{post|comment}/{ownerID}/{index}.{ext}, where the index is the position in the submitted array. Re-committing therefore overwrites its own objects instead of accumulating garbage, and the original file extension is preserved.
  • The kind value (post or comment) is fixed at construction, so there are two instances rather than one that branches internally — the two layouts cannot drift apart.
  • The process is deliberately two-phase: phase one normalizes and calls HeadObject to prove every key first; phase two then copies. A request containing even one broken reference writes nothing at all.
  • normalizeTempImageKey accepts both a raw key of the form temp/temp-{ms}.{ext} and the public URL the upload endpoint returned, normalizing either into a key. Anything else — another prefix, path traversal, or arbitrary text — is rejected.
  • HeadObject is the real security step. It is the only thing preventing a reference to another tenant's object or to a key that was never uploaded. Failure returns 400 with BULLETIN_INVALID_IMAGE.
  • With no storage configuration the committer fails closed and rejects every image, rather than letting temp keys through to be persisted — the original defect that made every image 404 once the temp prefix was swept.
  • The objectStore interface deliberately has no delete method. Objects under temp/ are never removed here: deleting them would mean a transaction rollback leaves both the source gone and nothing pointing at a copy. Sweeping temp/ is a separate operations task.

Key Files & Functions

RouteRate limitHandler
POST /api/bulletin/:hash/posts5/60s(*Handler).CreatePost
PUT /api/bulletin/:hash/posts/:id(*Handler).UpdatePost
DELETE /api/bulletin/:hash/posts/:id(*Handler).DeletePost
PUT /api/bulletin/:hash/posts/:id/comments-setting(*Handler).SetCommentsSetting
  • internal/bulletin/service.goCreatePost, UpdatePost, DeletePost, SetAllowComments, ownedPost, validateTitle, the postRepo interface, AuditEntry, ImageCommitter
  • internal/bulletin/committer.goNewStorageCommitter, (storageCommitter).Commit, permanentKey, extensionOf, objectStore
  • internal/bulletin/register.gonormalizeTempImageKey, tempImageKeyRE
  • internal/bulletin/repository.goWithTx, InsertPost, UpdatePost, UpdatePostImages, SoftDeletePost, SetAllowComments, InsertAudit, FindPostByID, FindCategoryByID

A note on route naming: every wildcard under /posts/:id must use the same :id name, because gin panics when parameter names differ within a single subtree. /comments/:id is a separate subtree thanks to its differing static segment.

Connections to Other Services

  • Database — tables bulletin.post, bulletin.category, and bulletin.audit_log
  • Object storage — reached through internal/storagex via HeadObject and CopyObject
  • File upload — the source of temp keys is the POST /upload-file/temp endpoint
  • Bulletin access control — calls CanWrite and reads the various caps from settings
  • client-web — corresponds to bulletin-board (the post composer), bulletin-moderation (the owner's edit and delete menu), and file-upload