หน้าอ่านบทความ
ภาพรวม
หน้าอ่านบทความคือหน้าจอที่แสดงเนื้อหาที่แอดมินสร้างไว้จากฝั่ง CMS รองรับความสามารถหลายอย่างที่จำเป็นสำหรับการเผยแพร่เนื้อหาจริง ได้แก่ เนื้อหาสองภาษา (ไทยและอังกฤษ) การล็อกเนื้อหาด้วยรหัสผ่าน การจำกัดการเข้าถึงเฉพาะบางกลุ่มผู้ใช้ซึ่งต้องล็อกอิน LINE การสร้าง Open Graph metadata สำหรับการแชร์ลิงก์ และปุ่มเชิญเพิ่มเพื่อนที่ตรึงอยู่ด้านล่างจอ
เนื้อหาถูกเก็บในรูปแบบ HTML ที่สร้างจาก Tiptap editor ในฝั่ง CMS แล้วนำมาเรนเดอร์ด้วย Tiptap ในโหมดอ่านอย่างเดียว โปรเจกต์นี้ ไม่มีการใช้ dangerouslySetInnerHTML ที่จุดใดเลย และมี guard test บังคับกฎนี้ไว้
Business Flow
- ผู้ใช้เข้าสู่ path
/:hash/content/:token - ฝั่งเซิร์ฟเวอร์: layout ของหน้าเรียก endpoint เนื้อหาในโหมดขอเฉพาะ metadata พร้อมกำหนด revalidate 60 วินาที เพื่อสร้าง OG และ meta tag โดยเลือกหัวข้อตามลำดับความสำคัญคือหัวข้อสำหรับ OG ก่อน แล้วหัวข้อสำหรับ SEO และหัวข้อบทความตามลำดับ หากเนื้อหานั้นถูกล็อกด้วยรหัสผ่าน ระบบจะส่งเพียง metadata ขั้นต่ำเพื่อไม่ให้เนื้อหารั่วไหลผ่าน link preview
- ฝั่ง Client: โหลดข้อมูล OA จาก hash แล้วยิงคำขอดึงเนื้อหา โดยแนบ
x-liff-tokenเฉพาะกรณีที่มี session LIFF อยู่แล้ว - เส้นทางการทำงานแยกตามผลลัพธ์ที่ API ตอบกลับ ตามตารางด้านล่าง
- การเลือกภาษาใช้ภาษาไทยก่อนแล้ว fallback เป็นภาษาอังกฤษ หากบทความมีมากกว่าหนึ่งภาษา ระบบจะแสดงตัวสลับภาษาไว้ที่ส่วนหัวแบบ sticky
- หากบทความผูกกับแคมเปญเพิ่มเพื่อน ระบบจะแสดงปุ่ม CTA ตรึงไว้ด้านล่างจอ เมื่อกดจะพาไปยัง LIFF URL ของแคมเปญนั้น พร้อมส่งค่า referral ต่อไปหากมี
- ค่า referral อ่านได้ทั้งจาก query parameter โดยตรง และจากภายใน
liff.stateเนื่องจากขั้นตอนล็อกอิน LIFF ทำให้ query parameter เดิมหายไป
การจัดการผลลัพธ์จาก API
| ผลลัพธ์ | พฤติกรรมของหน้าจอ |
|---|---|
| สำเร็จตามปกติ | เรนเดอร์เนื้อหาบทความ |
| ต้องใช้รหัสผ่าน | แสดงหน้าจอกรอกรหัสผ่าน แล้วยิงคำขอซ้ำพร้อมรหัสที่กรอก หากรหัสไม่ถูกต้องจะแจ้งข้อความให้ผู้ใช้ทราบ |
| ไม่ได้รับอนุญาต (403) | เริ่มกระบวนการล็อกอิน LIFF ณ จุดนั้น เนื่องจากเนื้อหาสาธารณะไม่จำเป็นต้องล็อกอิน ระบบจึงล็อกอินเฉพาะเมื่อจำเป็นจริง เมื่อได้โปรไฟล์แล้วจะดึงเนื้อหาใหม่อีกครั้ง หากยังไม่ผ่านเงื่อนไข audience จะแสดงหน้าปฏิเสธการเข้าถึง |
| ข้อผิดพลาดอื่น | แสดงหน้า Not Found |
หน้าจอและองค์ประกอบหลัก
หน้าเพจ
- หน้าอ่านบทความ (
src/app/[hash]/content/[token]/page.tsx) จัดการ flow ทั้งหมดตั้งแต่การโหลดข้อมูล การตัดสินใจว่าต้องขอรหัสผ่านหรือล็อกอินหรือไม่ ไปจนถึงการเลือกภาษา - Layout ของหน้า (
layout.tsx) รับผิดชอบการสร้าง metadata ฝั่งเซิร์ฟเวอร์ - Hook ดึงเนื้อหา (
hooks/useFetchContent.ts) ตั้งค่าไม่ให้ retry อัตโนมัติ กำหนด stale time 5 นาที และดึง ID token จาก LIFF ให้เองเมื่อผู้ใช้ล็อกอินอยู่แล้ว
คอมโพเนนต์การแสดงเนื้อหา (อยู่ภายใต้ src/components/content-viewer/)
- Tiptap viewer เรนเดอร์เนื้อหา HTML ในโหมดอ่านอย่างเดียว
- หน้าจอกรอกรหัสผ่านสำหรับเนื้อหาที่ถูกล็อก
- หน้าจอปฏิเสธการเข้าถึงสำหรับกรณีที่ผู้ใช้ไม่อยู่ในกลุ่ม audience ที่กำหนด
- ตัวสลับภาษาไทยและอังกฤษ
- ปุ่ม CTA เชิญเพิ่มเพื่อน ซึ่งมีแอนิเมชันดึงความสนใจ
- Extension ของ Tiptap ที่พัฒนาเพิ่มเอง ครอบคลุมการกำหนดระยะห่างบรรทัด สีพื้นหลังข้อความ การไฮไลต์แบบปลอดภัย และโมดูลตรวจสอบความปลอดภัยของค่า CSS
Service และชนิดข้อมูล
- Content service (
src/service/content.service.ts) ให้ฟังก์ชันดึงบทความตาม token รวมถึงฟังก์ชันดึงรายการบทความและหมวดหมู่ - ชนิดข้อมูลของบทความ คำแปล การผูกแคมเปญ และรูปแบบ response กรณีต้องใช้รหัสผ่าน อยู่ใน
src/service/types/content.type.ts
Endpoint ที่ใช้
GET /public-content/content/:tokenรับ query สำหรับรหัสผ่านและโหมดขอเฉพาะ metadata และรับ headerx-liff-tokenGET /public-content/contentsและ endpoint หมวดหมู่ มี service รองรับไว้แล้วแต่ยังไม่มีหน้าจอที่เรียกใช้
ความปลอดภัยของ CSS และ HTML
โมดูลตรวจสอบ CSS ทำ allowlist อย่างเข้มงวดที่ทุกจุดที่มีการฉีดค่า CSS ค่าสีต้องอยู่ในรูป hex หรือฟังก์ชัน rgb() หรือ rgba() เท่านั้น และค่าระยะห่างบรรทัดต้องเป็นตัวเลขพร้อมหน่วยที่อนุญาต
หลักการสำคัญคือ หากค่าใดไม่ผ่านการตรวจสอบ ระบบจะ ไม่ออก style นั้นเลย แทนที่จะพยายาม escape เพียงบางส่วน วิธีนี้ป้องกันการปิด declaration แล้วแทรก CSS อันตรายต่อท้าย เช่น การใช้ url() เพื่อส่งข้อมูลออกภายนอก หรือการวาง overlay แบบ fixed ทับหน้าจอ ซึ่งอันตรายเป็นพิเศษเพราะหน้านี้ทำงานใน webview ที่มี session LINE อยู่
ส่วนนี้มี unit test กำกับทั้งกฎห้ามใช้ raw HTML การตรวจสอบค่า CSS และการทำงานของ extension ไฮไลต์
จุดเชื่อมต่อกับฟีเจอร์อื่น
- Tiptap พร้อม StarterKit และ extension สำหรับลิงก์ การจัดตำแหน่งข้อความ และรูปภาพที่ปรับขนาดได้ เป็นแกนของการเรนเดอร์เนื้อหา
- Ant Design ให้คอมโพเนนต์พื้นฐานทั้งตัวหมุนรอโหลด ระบบ typography การ์ด ช่องกรอกรหัสผ่าน และหน้าจอแสดงผลลัพธ์
- เชื่อมกับ แคมเปญติดตามการเพิ่มเพื่อน ผ่านข้อมูลแคมเปญที่แนบมากับบทความ
- ผู้ใช้เดินทางมาที่หน้านี้จาก เมนูสาธารณะ และ หน้ารายการเนื้อหา
- Tiptap viewer ถูกใช้ซ้ำใน หน้าขอบคุณหลังส่งฟอร์ม สำหรับเนื้อหาแบบปรับแต่งเอง
รายละเอียดฝั่ง Backend (Client API)
:token รับได้ทั้ง public token และ slug
backend ค้นบทความด้วย public token ก่อน ถ้าไม่พบจะลองค้นด้วย slug อีกรอบ แล้วจึงตอบ 404 Content not found — หน้าเว็บจึงใช้ URL ที่อ่านง่ายแบบ slug ได้โดยไม่ต้องแก้อะไร และการที่ลิงก์ slug ใช้งานได้เป็นพฤติกรรมที่ตั้งใจ ไม่ใช่ผลข้างเคียง
response มี 3 รูปที่ต่างกัน ขึ้นกับโหมด
endpoint เดียวนี้คืน body คนละรูปกันตาม branch ที่เข้า ซึ่งอธิบายว่าทำไมฝั่งเว็บต้องแยกเส้นทางการจัดการผลลัพธ์:
1. โหมดขอเฉพาะ metadata (ใช้ตอน server-render OG tag)
- ข้ามการเช็ค audience และรหัสผ่านทั้งหมด และไม่นับ view — สำคัญมากเพราะทำให้จำนวน view ไม่ถูกปั่นจากการที่ link preview ถูกดึงซ้ำๆ โดย crawler
- คืนเฉพาะฟิลด์ meta/OG ของทุกภาษา ไม่มีเนื้อหาบทความ ดังนั้น การรั่วไหลผ่าน link preview จึงถูกกันที่ backend ด้วย ไม่ใช่แค่ฝั่งเว็บ
- backend สร้าง excerpt ให้อัตโนมัติ เมื่อบทความไม่มีทั้ง excerpt, OG description และ meta description แต่มีเนื้อหา: โดยลบ HTML tag ออก แปลง
เป็นช่องว่าง ยุบช่องว่างซ้ำ แล้วตัดที่ 200 ตัวอักษรพร้อมเติมจุดไข่ปลา — นับเป็นตัวอักษรจริง (rune) จึงตัดข้อความไทยได้ถูกต้อง
2. โหมดขอรหัสผ่าน — quirk ที่ต้องรู้
- บทความที่ล็อกรหัสผ่านตอบ HTTP 200 ไม่ใช่ 401 หรือ 403 ทั้งกรณีที่ยังไม่ส่งรหัสมาและกรณีที่ส่งรหัสผิด — เป็น quirk ของ controller เดิมที่ port มาตรงๆ
- ฝั่งเว็บต้อง อ่านจาก body ไม่ใช่จาก status code: ยังไม่ส่งรหัส → body มี
requirePassword: trueพร้อมข้อความว่าเนื้อหาถูกล็อก; ส่งรหัสผิด → body มีrequirePassword: trueพร้อมerror: "INVALID_PASSWORD" - เทียบรหัสด้วย bcrypt โดย hash ที่พังรูปหรือว่างจะถือว่าไม่ตรง (ไม่ error) และ hash ที่ระบบเดิมเขียนไว้ใช้ format เดียวกันจึงยังตรวจผ่านได้ทั้งหมด
3. โหมดเนื้อหาเต็ม — คืนเนื้อหาทุกภาษา พร้อมข้อมูลแคมเปญเพิ่มเพื่อน (ถ้ามี)
การจำกัดผู้อ่านด้วย audience — ทำไมเป็น 403 เสมอ ไม่ใช่ 401
- บทความที่กำหนด audience ไว้และ ไม่มี
x-liff-tokenมาเลย → 403Authentication required for this content(ไม่ใช่ 401 แม้เนื้อความจะบอกว่าต้องล็อกอิน) — ตรงกับที่หน้าเว็บใช้ 403 เป็นสัญญาณว่า "ถึงเวลาเริ่มล็อกอิน LIFF" - ถ้ามี token จะ verify ด้วยเส้นทางเฉพาะของเนื้อหา ซึ่งใช้ primary channel เท่านั้น ไม่มี fallback ไป channel ของฟอร์ม และ error ทุกกรณีเป็น 403 ไม่ว่าจะ token ผิด channel ผิด หรือ audience ไม่ตรง
- audience ไม่ทับกัน → 403
You do not have access to this content - ผลที่ตามมา: หน้าเว็บ แยกไม่ออกจาก status code ว่า 403 หมายถึง "ยังไม่ล็อกอิน" หรือ "ล็อกอินแล้วแต่ไม่มีสิทธิ์" จึงต้องใช้กลยุทธ์ลองล็อกอินก่อนหนึ่งครั้ง แล้วถ้ายังได้ 403 อีกจึงแสดงหน้าปฏิเสธการเข้าถึง — ซึ่งเป็นสิ่งที่หน้านี้ทำอยู่แล้ว
การนับ view และบล็อกเพิ่มเพื่อน
- จำนวน view ถูกเพิ่มหลังผ่านทุกด่านแล้ว (audience และรหัสผ่าน) และเฉพาะในโหมดเนื้อหาเต็มเท่านั้น — การกรอกรหัสผ่านผิดหรือถูกปฏิเสธ audience จึงไม่นับเป็น view
- ผลข้างเคียงที่ควรรู้: ทุกครั้งที่หน้าเว็บดึงเนื้อหาใหม่จะนับ view เพิ่ม ดังนั้นการ refresh หน้า หรือการยิงซ้ำหลังล็อกอินเพื่อผ่าน audience จะเพิ่มตัวเลข view ด้วย ตัวเลขนี้จึงหมายถึง "จำนวนการโหลดเนื้อหาสำเร็จ" ไม่ใช่ "จำนวนผู้อ่านไม่ซ้ำ"
- ถ้าบทความผูกกับแคมเปญเพิ่มเพื่อน backend จะ resolve token ของแคมเปญมาให้พร้อมข้อความบนปุ่ม (default คือ "เพิ่มเพื่อน") และ LIFF ID ที่ต้องใช้ — หน้าเว็บจึงประกอบ CTA ได้เองโดยไม่ต้องเรียก endpoint เพิ่ม
endpoint รายการบทความที่ service รองรับไว้แล้ว
แม้หน้านี้ยังไม่มีจอที่เรียกใช้ แต่ backend มี endpoint รายการบทความสาธารณะพร้อมใช้งาน และมีข้อจำกัดที่ควรรู้ก่อนนำมาต่อจอ (รายละเอียดเต็มอยู่ที่ หน้ารายการเนื้อหา):
- เป็น endpoint สาธารณะที่มี rate limit เฉพาะ route คือ 10 ครั้งต่อ 60 วินาทีต่อ IP เข้มกว่า rate limit ระดับแอปทั่วไป
lineOaIdบังคับ และ OA ต้อง active ไม่งั้น 404Invalid or inactive channel- คำค้นถูก sanitize ก่อน validate โดยลบอักขระกลุ่ม
<>'"%;()&ทิ้งแล้วบังคับให้เหลือเฉพาะตัวอักษรไทย/อังกฤษ ตัวเลข ช่องว่าง ขีดกลางและขีดล่าง ยาวไม่เกิน 100 — และคำค้นที่สั้นกว่า 2 ตัวอักษรจะได้ 400 - มี การกัน deep pagination: ถ้าตำแหน่งเริ่มต้นเกิน 500 รายการจะได้ 400
Page number exceeds maximum allowedดังนั้นรายการที่ยาวมากต้องใช้ตัวกรองช่วย ไม่ใช่ไล่หน้าไปเรื่อยๆ - ช่วงวันที่เผยแพร่กว้างเกิน 365 วันจะถูกปฏิเสธ
Edge case ที่ควรรู้
- บทความที่ยังไม่เผยแพร่จะถูกมองเป็น "ไม่พบ" (404) ไม่มีสถานะแยก — จึงไม่รั่วว่ามี draft อยู่จริง
- โหมด metadata ไม่ถูกจำกัดด้วย audience เลย ถ้าเพิ่มฟิลด์อ่อนไหวเข้าไปในชุด meta ในอนาคต จะรั่วออกทางนี้ทันที เป็นจุดที่ต้องระวังเวลาแก้ไข
- backend ส่ง translations ของ ทุกภาษามาพร้อมกันในครั้งเดียว การสลับภาษาบนหน้าเว็บจึงไม่ต้องยิง API ใหม่และไม่นับ view เพิ่ม