Skip to main content

บอร์ดประกาศ - สิทธิ์และการรายงาน

ภาพรวม​

เอกสารนี้อธิบายชั้นควบคุมของบอร์ดประกาศ ซึ่งประกอบด้วยสามส่วนที่ทำงานเกี่ยวเนื่องกัน:

  1. สิทธิ์การใช้งาน (capability) — ผู้ใช้คนนี้ทำอะไรได้บ้าง เป็นสมาชิกที่เขียนได้ เป็นผู้เยี่ยมชมที่อ่านได้เท่านั้น หรือถูกบล็อกไปแล้ว
  2. การรายงานเนื้อหา — ช่องทางให้ผู้ใช้แจ้งประกาศหรือความคิดเห็นที่ไม่เหมาะสม
  3. เมนูของเจ้าของโพสต์ — แก้ไข ลบ และเปิด/ปิดการแสดงความคิดเห็นของประกาศตัวเอง

จุดที่ต้องเข้าใจก่อนอ่านรายละเอียด: API ที่ส่งข้อมูลบอร์ดกลับมา ไม่ได้บอกว่าผู้เรียก เป็นใครหรือมีสิทธิ์อะไร การบังคับสิทธิ์ทั้งหมดอยู่ที่ server ฝั่งหน้าเว็บจึงต้องเดา แบบมองโลกในแง่ดี แล้วค่อยเรียนรู้จาก error ที่ได้กลับมา แนวทางนี้ทำให้ผู้ใช้ทั่วไป ไม่ต้องรอการตรวจสิทธิ์เพิ่มก่อนเห็นช่องเขียน

Business Flow​

สิทธิ์การใช้งาน​

  1. หากการตั้งค่าบอร์ดเปิดให้ผู้เยี่ยมชมเขียนได้ ทุกคนที่เข้ามาถึงหน้านี้ถือเป็นสมาชิก ที่เขียนได้ทันที
  2. หากไม่ได้เปิด สถานะสิทธิ์จะเป็น "ยังไม่ทราบ" แต่ระบบยังคงแสดงช่องเขียนไว้ก่อน (optimistic)
  3. เมื่อผู้ใช้ลองเขียนแล้วถูกปฏิเสธด้วยสถานะ 403 ระบบจะอัปเดตสิทธิ์ตามรหัส error ที่ได้กลับมา — ไม่มีสิทธิ์เขียน จะกลายเป็นผู้เยี่ยมชม ส่วนถูกบล็อก จะกลายเป็นสถานะถูกบล็อก
  4. ผลลัพธ์นี้ถูกจำไว้ใน sessionStorage ต่อ OA หนึ่งราย ทำให้ผู้ใช้ที่ถูกบล็อก เห็นช่องเขียนเพียงครั้งเดียวต่อ session ไม่ใช่ทุกครั้งที่เลื่อนหน้า
  5. การอ่านค่าจาก sessionStorage ทำผ่าน useSyncExternalStore พร้อมชุด listener ของตัวเอง เพราะเหตุการณ์ storage ของเบราว์เซอร์ไม่ยิงในแท็บที่เป็นผู้เขียนค่าเอง

การรายงานเนื้อหา​

  1. ปุ่มรายงานแสดงเมื่อประกาศหรือความคิดเห็นนั้นไม่ใช่ของผู้ใช้เอง และจะถูกปิด เมื่อบอร์ดตั้งค่าไม่อนุญาตให้ผู้ใช้รายงาน หรือเมื่อผู้ใช้ถูกบล็อก
  2. ผู้ใช้เลือกเหตุผลจากชุดที่กำหนดไว้ล่วงหน้า ได้แก่ สแปม การคุกคาม เนื้อหาไม่เหมาะสม ข้อมูลบิดเบือน และอื่น ๆ — ค่าอื่นนอกชุดนี้ server จะปฏิเสธ
  3. เพิ่มรายละเอียดประกอบได้ไม่เกิน 500 ตัวอักษร แล้วส่งรายงาน
  4. เมื่อสำเร็จ server ตอบสถานะ 201 โดยไม่มีเนื้อหากลับมา จึงไม่มีรหัสรายงาน ให้แสดงหรืออ้างอิงต่อ
  5. หากรายงานเนื้อหาเดิมซ้ำ จะได้สถานะ 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 ปิด (สแปม, การคุกคาม, เนื้อหาไม่เหมาะสม, ข้อมูลบิดเบือน, อื่นๆ) ไม่งั้น 400 BULLETIN_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 ของบอร์ดเอง ไม่งั้น 400 BULLETIN_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 โพสต์ไหม ไม่ใช่ → 403 BULLETIN_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 แยก