Skip to main content

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

  1. ผู้ใช้กดลิงก์ /c/{token} ซึ่งแนบมากับข้อความแคมเปญที่ระบบ worker ส่งออกไป
  2. Route Handler อ่านค่า base URL ของ API ภายในจาก BASE_API_INTERNAL_URL และใช้ PUBLIC_API_URL เป็นค่าสำรอง — ถ้าไม่ได้ตั้งค่าทั้งคู่จะตอบกลับ สถานะ 500 พร้อมข้อความว่ายังไม่ได้ตั้งค่าบริการ redirect
  3. ปรับรูปแบบ base URL ให้เป็นมาตรฐาน โดยตัดเครื่องหมาย / ท้ายและตัดส่วน /api ท้าย ออก จากนั้นประกอบเป็น URL ปลายทางพร้อม encode ค่า token
  4. เรียก upstream ด้วยโหมด redirect: 'manual' และ cache: 'no-store' การใช้โหมด manual สำคัญมาก เพราะทำให้ ส่งสถานะ 302 กลับไปให้เบราว์เซอร์ เดินทางเอง แทนที่ server จะเดินตาม redirect แล้วดึงเนื้อหาปลายทางมาส่งต่อ
  5. คัดลอกเฉพาะ header ที่อยู่ในรายการอนุญาต ได้แก่ content-type, content-length, cache-control, location, referrer-policy และ x-cache
  6. ส่งเนื้อหาตอบกลับแบบ stream เพื่อรองรับกรณีที่ปลายทางตอบเป็นไฟล์หรือรูปภาพ (กรณี 302 เนื้อหาจะว่างอยู่แล้ว)
  7. หากการเรียก 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 อนุญาตไว้พอดี