บอร์ดประกาศ - สิทธิ์และการรายงาน
ภาพรวม
เอกสารนี้อธิบายชั้นควบคุมของบอร์ดประกาศ ซึ่งประกอบด้วยสามส่วนที่ทำงานเกี่ยวเนื่องกัน:
- สิทธิ์การใช้งาน (capability) — ผู้ใช้คนนี้ทำอะไรได้บ้าง เป็นสมาชิกที่เขียนได้ เป็นผู้เยี่ยมชมที่อ่านได้เท่านั้น หรือถูกบล็อกไปแล้ว
- การรายงานเนื้อหา — ช่องทางให้ผู้ใช้แจ้งประกาศหรือความคิดเห็นที่ไม่เหมาะสม
- เมนูของเจ้าของโพสต์ — แก้ไข ลบ และเปิด/ปิดการแสดงความคิดเห็นของประกาศตัวเอง
จุดที่ต้องเข้าใจก่อนอ่านรายละเอียด: API ที่ส่งข้อมูลบอร์ดกลับมา ไม่ได้บอกว่าผู้เรียก เป็นใครหรือมีสิทธิ์อะไร การบังคับสิทธิ์ทั้งหมดอยู่ที่ server ฝั่งหน้าเว็บจึงต้องเดา แบบมองโลกในแง่ดี แล้วค่อยเรียนรู้จาก error ที่ได้กลับมา แนวทางนี้ทำให้ผู้ใช้ทั่วไป ไม่ต้องรอการตรวจสิทธิ์เพิ่มก่อนเห็นช่องเขียน
Business Flow
สิทธิ์การใช้งาน
- หากการตั้งค่าบอร์ดเปิดให้ผู้เยี่ยมชมเขียนได้ ทุกคนที่เข้ามาถึงหน้านี้ถือเป็นสมาชิก ที่เขียนได้ทันที
- หากไม่ได้เปิด สถานะสิทธิ์จะเป็น "ยังไม่ทราบ" แต่ระบบยังคงแสดงช่องเขียนไว้ก่อน (optimistic)
- เมื่อผู้ใช้ลองเขียนแล้วถูกปฏิเสธด้วยสถานะ 403 ระบบจะอัปเดตสิทธิ์ตามรหัส error ที่ได้กลับมา — ไม่มีสิทธิ์เขียน จะกลายเป็นผู้เยี่ยมชม ส่วนถูกบล็อก จะกลายเป็นสถานะถูกบล็อก
- ผลลัพธ์นี้ถูกจำไว้ใน
sessionStorageต่อ OA หนึ่งราย ทำให้ผู้ใช้ที่ถูกบล็อก เห็นช่องเขียนเพียงครั้งเดียวต่อ session ไม่ใช่ทุกครั้งที่เลื่อนหน้า - การอ่านค่าจาก
sessionStorageทำผ่านuseSyncExternalStoreพร้อมชุด listener ของตัวเอง เพราะเหตุการณ์storageของเบราว์เซอร์ไม่ยิงในแท็บที่เป็นผู้เขียนค่าเอง
การรายงานเนื้อหา
- ปุ่มรายงานแสดงเมื่อประกาศหรือความคิดเห็นนั้นไม่ใช่ของผู้ใช้เอง และจะถูกปิด เมื่อบอร์ดตั้งค่าไม่อนุญาตให้ผู้ใช้รายงาน หรือเมื่อผู้ใช้ถูกบล็อก
- ผู้ใช้เลือกเหตุผลจากชุดที่กำหนดไว้ล่วงหน้า ได้แก่ สแปม การคุกคาม เนื้อหาไม่เหมาะสม ข้อมูลบิดเบือน และอื่น ๆ — ค่าอื่นนอกชุดนี้ server จะปฏิเสธ
- เพิ่มรายละเอียดประกอบได้ไม่เกิน 500 ตัวอักษร แล้วส่งรายงาน
- เมื่อสำเร็จ server ตอบสถานะ 201 โดยไม่มีเนื้อหากลับมา จึงไม่มีรหัสรายงาน ให้แสดงหรืออ้างอิงต่อ
- หากรายงานเนื้อหาเดิมซ้ำ จะได้สถานะ 409 และแสดงข้อความว่าเคยรายงานไปแล้ว
เมนูของเจ้าของโพสต์
แสดงเมื่อประกาศนั้นเป็นของผู้ใช้เอง มีสามการกระทำ:
| การกระทำ | Endpoint | หมายเหตุ |
|---|---|---|
| แก้ไข | PUT /bulletin/{hash}/posts/{id} | ไม่รองรับการเปลี่ยนหมวดหมู่ จึงไม่มีตัวเลือกหมวดในฟอร์มแก้ไข หัวข้อยังบังคับกรอก ส่วนรูปภาพ หากไม่ส่งมาคือคงเดิม ส่งเป็นรายการว่างคือลบทั้งหมด |
| ลบ | DELETE /bulletin/{hash}/posts/{id} | ตอบสถานะ 204 |
| เปิด/ปิดความคิดเห็น | PUT /bulletin/{hash}/posts/{id}/comments-setting | ต้องส่งค่าสถานะใน body ทุกครั้ง |
รีแอ็กชัน
- แตะอิโมจิเดิมซ้ำคือการยกเลิก ฝั่ง server ตีความว่า ยังไม่มีรีแอ็กชันคือเพิ่ม อิโมจิต่างจากเดิมคือเปลี่ยน และอิโมจิเดิมซ้ำคือลบ
- คำขอเดียวคืนค่าเป็นตารางนับจำนวนของทุกอิโมจิกลับมาทั้งชุด ไม่ใช่แค่ตัวที่กด
- ฝั่งหน้าเว็บ seed ค่าเริ่มต้นจากข้อมูลรีแอ็กชันของประกาศพร้อมรีแอ็กชันของผู้ใช้เอง เพื่อให้การกดซ้ำสลับสถานะได้ถูกต้องตั้งแต่ครั้งแรก
- เลือกได้เฉพาะอิโมจิที่อยู่ในชุดที่บอร์ดกำหนดไว้
การแปลงข้อความผิดพลาด
ตัวช่วยแปลง error รองรับสามรูปแบบที่พบจริง ได้แก่ error จาก axios, envelope เปล่าที่ไม่มีเนื้อหา และรูปแบบอื่น ๆ ที่ไม่คาดคิด โดยรู้ว่า envelope ของ API ประกอบด้วยสถานะ ข้อความ และชื่อ error และ ไม่มี field สำหรับรหัส error แยกต่างหาก รหัสของบอร์ดจึงมาปนอยู่ในข้อความ ซึ่งอาจเป็นสตริงเดี่ยวหรือรายการก็ได้
นอกจากนี้ยังมีการจำแนก error ที่ควร "เงียบ" คือกรณีผู้ใช้ถูกบล็อก ซึ่งจะไม่แสดง toast ใด ๆ เพราะการแจ้งซ้ำ ๆ ไม่ช่วยอะไรและเป็นการยืนยันสถานะโดยไม่จำเป็น
หน้าจอและองค์ประกอบหลัก
ทั้งหมดอยู่ใต้ src/app/[hash]/bulletin/:
- hook สิทธิ์การใช้งาน (
hooks/useBoardCapability.ts) — ให้ค่าสถานะสิทธิ์ปัจจุบัน และฟังก์ชันบันทึกเมื่อถูกปฏิเสธ - แผ่นรายงานเนื้อหา (
components/ReportSheet.tsx) — drawer เลือกเหตุผล และกรอกรายละเอียด พร้อมรายการเหตุผลที่กำหนดไว้เป็นค่าคงที่ - เมนูเจ้าของโพสต์ (
components/OwnerMenu.tsx) — dropdown สามการกระทำ - แถบรีแอ็กชัน (
components/ReactionBar.tsx) - ตัวแปลง error (
lib/errors.ts) — พร้อมตารางแปลข้อความเป็นภาษาไทย ทั้งแบบอ้างตามรหัสและอ้างตามสถานะ HTTP - ตัวช่วยเรื่องหมวดหมู่ (
lib/categories.ts) — กรองหมวดที่ผู้ใช้โพสต์ได้
รหัส error ของบอร์ดถูกรวบรวมไว้เป็นค่าคงที่ใน src/service/types/bulletin.type.ts
พร้อมค่าความยาวหัวข้อสูงสุด
จุดเชื่อมต่อกับฟีเจอร์อื่น
- Ant Design — ใช้
Drawer,Dropdown,Radio, ช่องข้อความหลายบรรทัด และระบบ message/modal ผ่านApp.useApp() useSyncExternalStoreของ React 19 — ใช้อ่านค่าsessionStorageอย่างถูกต้อง ให้ค่าที่แสดงตรงกับค่าที่เก็บจริงเสมอ- ผูกกับ bulletin-board และ bulletin-post-detail โดยตรง เพราะองค์ประกอบเหล่านี้ ถูกส่งเข้าไปเป็นส่วนหนึ่งของการ์ดประกาศและความคิดเห็น
- มีชุดทดสอบครอบคลุมการแปลง error แถบรีแอ็กชัน และสิทธิ์การโพสต์ตามหมวดหมู่
รายละเอียดฝั่ง Backend (Client API)
สิทธิ์ 5 ตัวที่ server บังคับจริง
การที่หน้าเว็บ "เดาแบบมองโลกในแง่ดี" ทำได้เพราะฝั่ง backend มีสิทธิ์ 5 ตัวที่ตรวจครบทุก endpoint จากบริบทสิทธิ์ที่คำนวณครั้งเดียวต่อ request:
| สิทธิ์ | กฎที่ server ใช้ |
|---|---|
| ดูบอร์ด | ชนิดผู้ใช้ต้องอยู่ใน view_access ไม่งั้น 403 BULLETIN_VIEW_FORBIDDEN |
| เขียนโพสต์ | อยู่ใน write_access (ไม่งั้น BULLETIN_WRITE_FORBIDDEN) และ ไม่มี block ที่ scope ไม่ใช่ comment → ไม่งั้น BULLETIN_BLOCKED |
| คอมเมนต์ | อยู่ใน write_access และ ไม่มี block ใดๆ เลย (ทั้งสอง scope ห้ามคอมเมนต์) |
| กดรีแอ็กชัน | กฎเดียวกับการเขียนโพสต์ — คนที่ถูก mute เฉพาะคอมเมนต์ ยังกดรีแอ็กชันได้ |
| รายงานเนื้อหา | ต้องเปิด allow_user_reports ก่อน (ไม่งั้น BULLETIN_REPORTS_DISABLED) แล้วจึงใช้กฎเดียวกับการเขียนโพสต์ |
- การตัดสิน block เป็น deny-by-default: scope ที่พิมพ์ผิดหรือเป็นค่าว่างถือเป็น block เต็ม ไม่ใช่ปล่อยผ่าน
- block ที่มีวันหมดอายุเป็นอดีตถือว่าไม่มีผล — repository กรองอยู่แล้ว และมีการเช็คซ้ำในชั้น capability เป็นเข็มขัดชั้นสอง
- รหัส error ทั้งชุดถูกส่งกลับ เป็นข้อความ ไม่ใช่ field แยก ซึ่งตรงกับที่ฝั่งเว็บอธิบายไว้ว่า ต้องอ่านรหัสจากข้อความ
การรายงานเนื้อหา
POST /api/bulletin/:hash/reports — rate limit 5 ครั้ง/60 วินาที
reasonต้องอยู่ใน allowlist ปิด (สแปม, การคุกคาม, เนื้อหาไม่เหมาะสม, ข้อมูลบิดเบือน, อื่นๆ) ไม่งั้น 400BULLETIN_INVALID_REASON— เหตุผลไม่ใช่แค่ความสะอาดของข้อมูล: คอลัมน์เป็นVARCHAR(50) NOT NULLและคิว moderation ฝั่ง CMS switch ตามรหัสเหล่านี้ เหตุผลที่ไม่รู้จักจะทำให้ UI ของแอดมิน render ข้อความของผู้โจมตีดิบๆ หรือหลุด switch ไปเลย- รายละเอียดยาวเกิน 500 rune → 400
BULLETIN_DETAIL_TOO_LONG— คอลัมน์เป็น TEXT จึงไม่มีขอบจากฐานข้อมูล ถ้าไม่ cap ที่ชั้นนี้ ผู้ใช้ที่ล็อกอินแล้วจะเขียนแถวขนาดไม่จำกัด เข้าหน้าจอแอดมินได้ - insert ใน transaction พร้อม audit log และบังคับ 1 รายงานต่อ (เนื้อหา, ผู้รายงาน) ตลอดกาล
ด้วย unique index — ซ้ำ → 409
"already reported"โดยแปลงจาก error ของ index จริง ไม่ใช่การ check-then-insert ซึ่งจะแข่งกันเองเมื่อกดสองครั้งเร็วๆ - ตอบ 201 โดยไม่มี body ซึ่งเป็นเหตุผลที่ฝั่งเว็บไม่มีรหัสรายงานไปแสดง
รีแอ็กชัน
PUT /api/bulletin/:hash/reactions — rate limit 30 ครั้ง/60 วินาที
- อิโมจิต้องอยู่ในชุด
reaction_emojisของบอร์ดเอง ไม่งั้น 400BULLETIN_INVALID_EMOJIถ้าไม่เช็ค setting นี้จะเป็นแค่ของประดับ: สตริงอะไรก็ถูกบันทึกแล้วไป render บนแถบรีแอ็กชัน ของทุกคน และสตริงที่ยาวกว่าVARCHAR(16)จะกลายเป็น error ของ Postgres ที่โผล่มาเป็น 500 แทน 400; การเช็คสมาชิกในชุดครอบทั้งเคสสตริงว่างและยาวเกินไปพร้อมกัน - toggle ทำใน transaction เดียวโดยยึด unique index
(ชนิดเนื้อหา, id, ผู้ใช้)- อิโมจิเดิมซ้ำ → DELETE (toggle off); DELETE ที่ไม่เจอแถวเพราะ request อื่นลบไปก่อน ถือเป็น no-op ไม่ใช่ error
- อื่นๆ → upsert ด้วย statement เดียว (insert-or-replace) ไม่ใช่ read-then-write เพราะการล็อกแถวที่ยังไม่มีอยู่ล็อกอะไรไม่ได้ — รีแอ็กชันครั้งแรกที่มาพร้อมกัน 2 request จึงต้องไม่กลายเป็น duplicate-key 500
- เขียน audit log ใน transaction เดียวกัน
- คืน ตารางนับจำนวนหลัง mutate เพื่อให้ client อัปเดตแถบได้ทันที
- รีแอ็กชันเป็นตารางเดียวใน domain นี้ที่ hard delete ที่อื่น soft delete ทั้งหมด
verifyTarget — ทำไมต้องเช็คการมองเห็น ไม่ใช่แค่การมีอยู่
ทั้งการกดรีแอ็กชันและการรายงานเรียก verifyTarget ก่อนแตะข้อมูล และด่านนี้เช็ค การมองเห็น
ไม่ใช่แค่ว่าเนื้อหามีอยู่:
- ถ้าข้าม predicate นั้น 2 endpoint นี้จะกลายเป็น oracle ที่บอกทั้ง "มีอยู่" และ "มี engagement เท่าไร" บนเนื้อหาที่กฎการมองเห็นหมวดหมู่ตั้งใจซ่อน — การตอบ 200 พร้อม emoji breakdown คือการยืนยันโพสต์ที่ทุกที่อื่นตอบ 404
- การรายงานหนักกว่านั้น เพราะ trigger ที่นับ
report_countจะทำงาน ทำให้คนนอกดันตัวเลข moderation ของเนื้อหาที่ไม่ควรรู้ว่ามีอยู่ได้ - เป้าที่เป็นคอมเมนต์ถูกเช็ค 2 ชั้น: สถานะของคอมเมนต์เอง และ การมองเห็นของโพสต์แม่ (คอมเมนต์บนโพสต์ที่ถูกลบหรือถูกซ่อนต้องเข้าถึงไม่ได้ด้วย) โดย 404 ของโพสต์แม่ถูกแปลงข้อความ เป็น "ไม่พบคอมเมนต์" ให้ทั้งสองเคสแยกไม่ออก
- ชนิดเป้าหมายอื่นนอกจากโพสต์และคอมเมนต์ → 400
"invalid target type"; ความล้มเหลวอื่นทั้งหมดเป็น 404 เดียวกัน
เมนูของเจ้าของโพสต์ — กฎฝั่ง server
- การสร้างโพสต์ (
POST /api/bulletin/:hash/posts, rate limit 5/60s) ตรวจหัวข้อโดย ลำดับของกฎเป็นส่วนหนึ่งของสัญญา: trim ก่อน แล้วเช็คว่าง (BULLETIN_EMPTY_TITLE) จากนั้นจึงเช็คความยาว (BULLETIN_TITLE_TOO_LONG, เพดาน 200 นับเป็น rune เพราะคอลัมน์VARCHAR(200)ของ Postgres นับเป็นตัวอักษร หัวข้อไทย 200 ตัวจึงต้องผ่าน) ค่าที่บันทึกจริง คือค่าที่ trim แล้ว ดังนั้นหัวข้อที่เป็นช่องว่าง 300 ตัวคือ "ว่าง" ไม่ใช่ "ยาวเกิน" - ถ้าระบุหมวดหมู่ ต้องผ่าน 2 ข้อ ตามลำดับนี้: ก่อนอื่นตรวจว่าหมวดนั้นอยู่บนบอร์ดของผู้เรียกจริง
(FK พิสูจน์แค่ว่าหมวดมีอยู่ ที่ไหนก็ได้) ไม่พบ → 400
"invalid category"ไม่ใช่ 404 แล้วจึงตรวจว่าหมวดเปิดให้ LINE user โพสต์ไหม ไม่ใช่ → 403BULLETIN_CATEGORY_ADMIN_ONLY— ลำดับนี้ทำให้หมวดของบอร์ดอื่นตอบ "invalid category" ไม่ใช่ "admin only" ซึ่งจะยืนยันว่ามีหมวด id นั้นอยู่ - การแก้ไข (
PUT) ไม่ได้รับข้อยกเว้นจากกฎหัวข้อ (แก้แล้วหัวข้อว่างจะเหลือกระทู้ไม่มีชื่อ ในลิสต์ที่นำด้วยหัวข้อ) และโพสต์ที่ published อยู่บนบอร์ดที่ต้องอนุมัติจะ กลับไปเป็น pending หลังแก้; input ของการแก้ไข ไม่มี field หมวดหมู่โดยเจตนา เพราะ repository ไม่มีคอลัมน์ให้เขียน และ field ที่รับแล้วทิ้งเงียบๆ แย่กว่าไม่มี — นี่จึงเป็นเหตุผลว่าทำไมการแก้ไขไม่ต้องเช็คสิทธิ์หมวดซ้ำ (แก้ไขไม่สามารถย้ายโพสต์เข้าหมวด admin-only ได้) - การลบ เป็น soft delete เสมอ (ตั้ง status และวันที่ลบ) ไม่เคยเป็น SQL DELETE
- ทุกการ mutate รันใน transaction เดียวพร้อมแถว audit log เพื่อไม่ให้มี audit trail ที่มีรูโหว่ ถ้าขั้นใดล้ม rollback ทั้งหมด
การยืนยันรูปภาพ — ด่านความปลอดภัยที่ซ่อนอยู่
รูปที่แนบมากับโพสต์และคอมเมนต์ถูก "commit" จาก temp/ ไป path ถาวรในรูปแบบ
bulletin/post-หรือ-comment/ownerID/index.นามสกุล — ไม่ใช่แค่การจัดระเบียบ แต่เป็น
การพิสูจน์ว่าผู้เรียกอัปโหลด object นั้นจริง
- ทำงาน 2 phase โดยเจตนา: phase 1 normalize key แล้วยืนยันว่าทุก key มีอยู่จริง phase 2 จึงคัดลอก — request ที่มี reference เสีย 1 ตัวจะไม่เขียนอะไรเลย
- การเช็คว่า object มีอยู่จริงคือ ขั้นความปลอดภัยจริง เป็นสิ่งเดียวที่กันการอ้าง object
ของ tenant อื่นหรือ key ที่ไม่เคยถูกอัปโหลด; ล้มเหลว → 400
BULLETIN_INVALID_IMAGE - key ที่รับได้มีเฉพาะ key ดิบใต้
temp/และ public URL ที่ endpoint อัปโหลดคืนมา อย่างอื่น (prefix อื่น, path traversal, ข้อความมั่ว) ถูกปฏิเสธ - ไม่มี config storage → fail closed ปฏิเสธทุกรูป ไม่ใช่ปล่อย temp key ผ่านไปเก็บ
ซึ่งเป็นข้อผิดพลาดเดิมที่ทำให้รูปทั้งหมด 404 เมื่อ prefix
temp/ถูกกวาด - index ในชื่อไฟล์คือตำแหน่งใน array ที่ส่งมา ดังนั้นการ commit ซ้ำจึง เขียนทับ object ของตัวเอง ไม่สะสมขยะ
- interface ของ object store ไม่มีเมธอดลบ โดยเจตนา — object ใน
temp/ไม่เคยถูกลบที่นี่ เพราะถ้าลบแล้ว transaction rollback จะเหลือทั้งต้นทางหายและไม่มีอะไรชี้ไปสำเนา การกวาดtemp/เป็นงาน operations แยก