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)
-
Call
resolveAccess, then checkCanWrite(). -
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 PostgresVARCHAR(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. -
A body longer than
max_body_lengthreturnsBULLETIN_BODY_TOO_LONG; more images thanmax_images_per_postreturnsBULLETIN_IMAGE_LIMIT. -
When a
categoryIdis supplied, two checks run in this order:- Ownership — the FK on
post.category_idonly proves the category exists somewhere, not that it belongs to the caller's board. A miss is therefore bad input: 400 withinvalid category, not 404. - Post access — only then examine the category's
post_access. Anything other thanmemberreturns 403 withBULLETIN_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.
- Ownership — the FK on
-
Determine status:
require_post_approvalyieldspending, otherwisepublished.allowCommentsuses the supplied value when present, otherwiseallow_comments_default— held as a pointer so "not supplied" is distinguishable from "supplied as false". -
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 fromUpdatePostbecause the latter stampsedited_date, and a post with images should not be marked "edited" at birth) → insert thepost.createaudit row. If any step fails, everything rolls back, so there is never an orphaned post pointing at an object that was never copied. -
Return the
PostViewwith status 201.
Editing a post — PUT /api/bulletin/:hash/posts/:id
- Check
CanWrite(), thenownedPost— missing, belonging to another OA, or not the caller's own all return the same 404. - 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.
- A post currently
publishedon a board withrequire_post_approvalset reverts topending; other statuses are left as they are. UpdatePostInputdeliberately has nocategoryIdfield — 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 whyUpdatePostneed not re-checkpost_access: an edit cannot move a post into an admin-only category. A test serves as the tripwire.- Commit images (only when
imageswas supplied), then update and insert thepost.editaudit 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_date — never 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
kindvalue (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
HeadObjectto prove every key first; phase two then copies. A request containing even one broken reference writes nothing at all. normalizeTempImageKeyaccepts both a raw key of the formtemp/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.HeadObjectis 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 withBULLETIN_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
objectStoreinterface deliberately has no delete method. Objects undertemp/are never removed here: deleting them would mean a transaction rollback leaves both the source gone and nothing pointing at a copy. Sweepingtemp/is a separate operations task.
Key Files & Functions
| Route | Rate limit | Handler |
|---|---|---|
POST /api/bulletin/:hash/posts | 5/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.go—CreatePost,UpdatePost,DeletePost,SetAllowComments,ownedPost,validateTitle, thepostRepointerface,AuditEntry,ImageCommitterinternal/bulletin/committer.go—NewStorageCommitter,(storageCommitter).Commit,permanentKey,extensionOf,objectStoreinternal/bulletin/register.go—normalizeTempImageKey,tempImageKeyREinternal/bulletin/repository.go—WithTx,InsertPost,UpdatePost,UpdatePostImages,SoftDeletePost,SetAllowComments,InsertAudit,FindPostByID,FindCategoryByID
A note on route naming: every wildcard under
/posts/:idmust use the same:idname, because gin panics when parameter names differ within a single subtree./comments/:idis a separate subtree thanks to its differing static segment.
Connections to Other Services
- Database — tables
bulletin.post,bulletin.category, andbulletin.audit_log - Object storage — reached through
internal/storagexviaHeadObjectandCopyObject - File upload — the source of temp keys is the
POST /upload-file/tempendpoint - Bulletin access control — calls
CanWriteand reads the various caps from settings - client-web — corresponds to
bulletin-board(the post composer),bulletin-moderation(the owner's edit and delete menu), andfile-upload