Proxy ลิงก์แคมเปญ (/c/{token})
ภาพรวม
/c/{token} เป็น Route Handler ฝั่ง server เพียงตัวเดียวของโปรเจกต์ client-web
ทำหน้าที่เป็น proxy บาง ๆ ส่งต่อคำขอไปยัง endpoint /api/c/:token ของ client-api
เหตุผลที่ต้องมีชั้นนี้คือ ลิงก์ติดตามของแคมเปญควรอยู่บนโดเมนสาธารณะเดียวกับที่ผู้ใช้
คุ้นเคยอยู่แล้ว ซึ่งก็คือโดเมนของ client-web แต่ตรรกะจริงทั้งหมด — การถอดรหัส token
การเขียน tracking_log การตอบ 302 หรือการส่งไฟล์กลับ — อยู่ที่ client-api
และ กุญแจเข้ารหัสไม่เคยหลุดออกมาถึงฝั่ง client-web เลย
ต่างจากลิงก์ติดตามการคลิกตรงที่หน้านี้ไม่มี UI ใด ๆ ผู้ใช้จะไม่เห็นหน้าจอกลาง
Business Flow
- ผู้ใช้กดลิงก์
/c/{token}ซึ่งแนบมากับข้อความแคมเปญที่ระบบ worker ส่งออกไป - Route Handler อ่านค่า base URL ของ API ภายในจาก
BASE_API_INTERNAL_URLและใช้PUBLIC_API_URLเป็นค่าสำรอง — ถ้าไม่ได้ตั้งค่าทั้งคู่จะตอบกลับ สถานะ 500 พร้อมข้อความว่ายังไม่ได้ตั้งค่าบริการ redirect - ปรับรูปแบบ base URL ให้เป็นมาตรฐาน โดยตัดเครื่องหมาย
/ท้ายและตัดส่วน/apiท้าย ออก จากนั้นประกอบเป็น URL ปลายทางพร้อม encode ค่า token - เรียก upstream ด้วยโหมด
redirect: 'manual'และcache: 'no-store'การใช้โหมด manual สำคัญมาก เพราะทำให้ ส่งสถานะ 302 กลับไปให้เบราว์เซอร์ เดินทางเอง แทนที่ server จะเดินตาม redirect แล้วดึงเนื้อหาปลายทางมาส่งต่อ - คัดลอกเฉพาะ header ที่อยู่ในรายการอนุญาต ได้แก่
content-type,content-length,cache-control,location,referrer-policyและx-cache - ส่งเนื้อหาตอบกลับแบบ stream เพื่อรองรับกรณีที่ปลายทางตอบเป็นไฟล์หรือรูปภาพ (กรณี 302 เนื้อหาจะว่างอยู่แล้ว)
- หากการเรียก upstream ล้มเหลว จะตอบกลับสถานะ 502 พร้อมข้อความ Bad gateway
หน้าจอและองค์ประกอบหลัก
ฟีเจอร์นี้ไม่มีหน้าจอ ทั้งหมดเป็นโค้ดฝั่ง server ที่ทำงานจบภายในคำขอเดียว
- Route Handler (
src/app/c/[token]/route.ts) — รองรับเฉพาะเมธอด GET กำหนดให้ทำงานบน Node runtime และบังคับเป็น dynamic route เพื่อไม่ให้ถูก cache ตอน build - รายการ header ที่อนุญาต เก็บเป็นค่าคงที่ในไฟล์เดียวกัน เพื่อไม่ให้ header ที่ไม่ตั้งใจหลุดผ่านไปหาผู้ใช้
- ปลายทาง upstream คือ
GET {client-api}/api/c/{token} - ตัวแปรสภาพแวดล้อมที่เกี่ยวข้อง:
BASE_API_INTERNAL_URLและPUBLIC_API_URL
จุดเชื่อมต่อกับฟีเจอร์อื่น
- ไม่ใช้ axios, React Query หรือ LIFF เลย เป็นการเรียก
fetchฝั่ง Node ล้วน ๆ จึงเบาและไม่มี dependency ผูกกับ UI - ผูกกับการออกแบบระบบส่งแคมเปญและการติดตามผลฝั่ง worker ซึ่งเป็นผู้สร้าง token และเป็นผู้ใส่ลิงก์นี้ลงในข้อความที่ส่งออก
- แยกบทบาทกับ ลิงก์ติดตามการคลิก อย่างชัดเจน: อันนั้นเป็นลิงก์สำหรับ rich menu ที่ต้องระบุตัวตนผ่าน LIFF ก่อน ส่วนอันนี้เป็นลิงก์ในข้อความแคมเปญที่ทำงานเงียบ ๆ ไม่มี UI
รายละเอียดฝั่ง Backend (Client API)
ปลายทางจริงคือ GET /api/c/:token ซึ่งเป็น endpoint เดียวใน client-api ที่ไม่ตอบ JSON
envelope เพราะผู้เรียกไม่ใช่ client-web แต่เป็น LINE client / in-app browser ของผู้ใช้
(ตอนกดลิงก์) หรือ image proxy ของ LINE เอง (ตอน render รูปในข้อความ) จึงต้องตอบเป็น
302 redirect หรือ stream ไบต์ของรูปกลับไปตรงๆ
token เข้ารหัสด้วย AES-256-GCM และพา URL ปลายทางกับบริบทการติดตามมาในตัว
ดังนั้นการคลิกหนึ่งครั้ง resolve ได้ โดยไม่อ่านฐานข้อมูลเลย ถอดรหัสไม่ได้ → 400
"Invalid token" (ตอบเป็น text/html ไม่ใช่ JSON)
การบันทึกสถิติ — "บันทึกก่อน แต่ไม่รอ"
- ถ้า token ยังไม่หมดอายุ ระบบจะแยก goroutine ไปเขียน
tracking_logโดย ตัดขาดจาก context ของ request เหตุผลคือลิงก์แคมเปญมักถูกกดแล้วผู้ใช้เด้งออกไปทันที ถ้าผูกกับ request context ไว้ การที่ client ตัดการเชื่อมต่อจะยกเลิกการเขียนสถิติทิ้ง - enum ต่างกันตามชนิดของ token: คลิก URL บันทึกเป็น
action_type='click',type='uri'ส่วนการเปิดรูปบันทึกเป็นaction_type='read',type='asset'โดยcontent_type='campaign'ทุกกรณี line_uidเป็น NULL เมื่อ token ไม่ได้ระบุผู้ใช้ (เช่น token แบบ broadcast ที่ใช้ค่า-) ซึ่งหมายถึง ไม่มี trigger รายบุคคล โดยเจตนา ไม่ใช่ข้อมูลขาด- ตั้ง header
Referrer-Policy: no-referrerทุกกรณี เพื่อไม่ให้ URL ที่มี token รั่วไปกับ referrer ของเว็บปลายทาง
ชนิด url — การหลบหน้าต่าง LIFF ซ้อน
- ถ้า token ถูกออกแบบ liff-entry (คือลิงก์เดิมอยู่ใต้
liff.line.me) backend จะ ไม่ redirect ไป URL absolute ของliff.line.meแต่แปลงเป็น path แบบ same-origin แทน เช่น URL รูปแบบhttps://liff.line.me/<liffId>/foo?x=1จะกลายเป็น/foo?x=1 - เหตุผล: ค่า
Locationที่ชี้ไปliff.line.meทำให้ LINE เปิด หน้าต่าง LIFF ที่สอง และสำหรับลิงก์ธรรมดาจะทิ้ง in-app browser ไว้เป็นหน้าต่างว่างที่ผู้ใช้ปิดไม่ได้ - ลิงก์เก่าที่ไม่ใช่ liff-entry ยัง redirect เหมือนเดิม เพื่อไม่ให้ฟีเจอร์ที่ต้องอยู่ในบริบท LIFF
(เช่นการเรียกกล้อง) เสียไป; URL ที่ไม่ใช่
liff.line.meหรือไม่มี sub-path ต่อท้าย liffId ก็ใช้ URL เดิม
ชนิด asset — การ proxy รูปภาพ
- ด่าน SSRF — ก่อนจะ fetch อะไรก็ตาม ระบบตรวจว่า URL ต้นทางไม่ได้ชี้ไปที่เครือข่ายภายใน
บล็อก
localhostและ subdomain ของมัน, IPv4 ในช่วงสงวน (10.x,127.x,0.x,169.254.xซึ่งเป็น cloud metadata endpoint,192.168.x,172.16–31.x) และ IPv6 แบบ loopback/link-local/unique-local — URL ที่ parse ไม่ได้ถือว่าไม่ปลอดภัย ไม่ใช่ปล่อยผ่าน → 403"Blocked: private URL" - เช็ค cache ก่อน fetch — key เป็น hash ของ URL ต้นทาง เก็บทั้ง content type และไบต์
ของไฟล์ hit จะตอบพร้อม header
X-Cache: HITและCache-Control: public, max-age=86400 - miss → fetch ต้นทางด้วย timeout 15 วินาที; ตอบไม่ใช่ 2xx → 502
"Failed to fetch asset" - อ่านได้สูงสุด 50MB เกินกว่านั้น → 502
"Asset too large" - cache เฉพาะไฟล์ที่ไม่เกิน 2MB อายุ 24 ชั่วโมง แบบ fire-and-forget — ไฟล์ใหญ่ยังส่งได้ แต่ไม่กิน Redis
- header ที่ตอบกลับคือ
Content-Typeจากต้นทาง (ค่า default เป็นapplication/octet-stream),Content-Length,Cache-Control: public, max-age=86400และX-Cache: MISS— ตรงกับรายการ header ที่ proxy ฝั่ง client-web อนุญาตไว้พอดี