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 แยก