ลิงก์แคมเปญและ Proxy รูปภาพ
ภาพรวม
endpoint เดียวใน service นี้ที่ ไม่ตอบเป็น JSON envelope เนื่องจากผู้เรียกไม่ใช่ client-web แต่เป็น LINE client หรือ in-app browser เอง (ตอนผู้ใช้กดลิงก์) หรือเป็น image proxy ของ LINE (ตอน render รูปในข้อความ) จึงต้องตอบเป็น 302 redirect หรือ stream bytes ของรูปออกไปโดยตรง
token ของ endpoint นี้เป็นแบบเข้ารหัสที่พา destination และบริบทการติดตามมาในตัว การคลิกหนึ่งครั้งจึง
resolve ได้โดยไม่ต้องอ่านฐานข้อมูลเลย มีเพียงการเขียน tracking_log แบบ async best-effort เท่านั้น
Business Flow
GET /api/c/:token
- ถอดรหัส token ด้วย keyring AES-256-GCM (
campaigntoken.Ring) หากล้มเหลวตอบ 400"Invalid token"ในรูปแบบ text/html - ตรวจวันหมดอายุจาก
payload.E - บันทึกก่อนแต่ไม่รอผล หาก token ยังไม่หมดอายุ ระบบจะ spawn goroutine เพื่อเขียน
tracking_logโดย detach จาก request context เพื่อให้ client ที่ตัดการเชื่อมต่อไปไม่ยกเลิกการเขียน- ค่า enum ต่างกันตามชนิดของการกระทำ: การคลิก URL ใช้
action_type='click'กับtype='uri'ส่วนการเปิดรูปใช้action_type='read'กับtype='asset' content_typeเป็นcampaignเสมอ ส่วนline_uidเป็น NULL เมื่อค่าuว่างหรือเป็น"-"ซึ่งหมายถึง token แบบ broadcast และหมายความว่าไม่มี trigger รายบุคคลโดยเจตนาraw_dataถูกส่งเป็น string ด้วยเหตุผลเดียวกับ tracking-redirect คือ pgx simple protocol กับคอลัมน์ jsonb
- ค่า enum ต่างกันตามชนิดของการกระทำ: การคลิก URL ใช้
- ตั้ง header
Referrer-Policy: no-referrerในทุกกรณี - ชนิด
urlตอบ 302- หาก token ถูก mint แบบ liff-entry (
payload.Fหมายถึงการกดที่จะเปิดหน้าต่าง LIFF เพราะลิงก์ อยู่ใต้โดเมนliff.line.me) ระบบจะ redirect ไปยัง path แบบ same-origin แทน URL absolute ของliff.line.meโดยliffRelativePathแปลง 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 เดิมตามที่ได้รับมา
- หาก token ถูก mint แบบ liff-entry (
- ชนิด
assetทำหน้าที่ proxy รูป ผ่านstreamAsset- SSRF guard (
isPrivateURL) บล็อกlocalhostและโดเมนย่อยของมัน, IPv4 ในช่วง reserved (10.x,127.x,0.x,169.254.xซึ่งเป็น cloud metadata,192.168.x,172.16–31.x) รวมถึง IPv6::1,fd*และfe80*ทั้งนี้ URL ที่ parse ไม่ได้ถือว่าไม่ปลอดภัย และตอบ 403"Blocked: private URL" - ตรวจ cache ก่อน โดยใช้ key
CAMPAIGN_ASSET:{sha256(originURL)}ซึ่งเก็บค่าในรูปแบบ"{contentType}|{base64}"เมื่อ hit จะตอบพร้อม headerX-Cache: HITและCache-Control: public, max-age=86400 - เมื่อ miss จะ fetch จาก origin ด้วย timeout 15 วินาที และ response ที่ไม่ใช่ 2xx ตอบ 502
"Failed to fetch asset" - อ่านได้สูงสุด 50MB (
assetMaxBytes) หากเกินตอบ 502"Asset too large" - cache เฉพาะไฟล์ที่ไม่เกิน 2MB (
assetCacheMaxBytes) ด้วย TTL 24 ชั่วโมง แบบ fire-and-forget - ตอบด้วย
Content-Typeจาก origin (ค่าเริ่มต้นapplication/octet-stream),Content-Length,Cache-Control: public, max-age=86400และX-Cache: MISS
- SSRF guard (
ไฟล์และฟังก์ชันหลัก
| รายการ | ค่า |
|---|---|
| Route | GET /api/c/:token |
| Register | internal/campaignredirect/register.go → Register(r, deps) (group /c) |
| Handler | internal/campaignredirect/handler.go → (*Handler).Handle ซึ่งเขียนลง c.Writer โดยตรงแล้วเรียก c.Abort() |
| Service | internal/campaignredirect/service.go → NewService, Handle, recordEvent, streamAsset, liffRelativePath, campaignID, writePlain |
| SSRF | internal/campaignredirect/ssrf.go → isPrivateURL, ipv4Literal |
| Token ring | internal/campaigntoken/token.go → NewRing, Decrypt, Payload |
| ค่าคงที่ | assetCacheMaxBytes=2MB, assetCacheTTL=86400, assetFetchTimeout=15s, assetMaxBytes=50MB |
จุดเชื่อมต่อกับ Service อื่น
- ตารางฐานข้อมูล
tracking_log(เขียนอย่างเดียว) - Redis ผ่าน
internal/cacheสำหรับ byte cache ของรูป โดย key อ้างอิงจาก origin URL - Config
CAMPAIGN_LINK_KEYSซึ่งเป็น keyring ที่ใช้ร่วมกับ tracking-redirect แต่แยกจากกันด้วย AAD รวมถึงdeps.Config.RedisCache.NamespaceและTTL - token ถูกสร้างโดย cms-api-go ในขั้นตอนสร้าง campaign หรือ rich message
- ตรงกับ client-web feature:
campaign-redirect-proxyโดย client-web มี Route Handler ฝั่ง server ที่ proxy มายัง endpoint นี้ เพื่อให้ลิงก์อยู่บนโดเมนสาธารณะ