Campaign Link Proxy (/c/{token})
Overview
/c/{token} is the only server-side route handler in the client-web project.
It acts as a thin proxy that forwards requests to the client-api endpoint
/api/c/:token.
The reason this layer exists is that campaign tracking links should live on the
same public domain users already recognize — the client-web domain — while all
the real logic stays on client-api: decrypting the token, writing to tracking_log,
returning a 302, or streaming a file back. Crucially, the encryption key never
reaches client-web.
Unlike the click tracking link, this route renders no UI at all; users never see an interstitial screen.
Business Flow
- The user taps a
/c/{token}link embedded in a campaign message sent by the worker service. - The route handler reads the internal API base URL from
BASE_API_INTERNAL_URL, falling back toPUBLIC_API_URL. If neither is configured it responds with a 500 and a message stating that the redirect service is not configured. - It normalizes the base URL by stripping any trailing
/and any trailing/apisegment, then assembles the upstream URL with the token URL-encoded. - It calls upstream with
redirect: 'manual'andcache: 'no-store'. The manual mode matters: it means the 302 is handed back to the browser to follow itself rather than the server following the redirect and relaying the destination content. - Only allow-listed headers are copied through:
content-type,content-length,cache-control,location,referrer-policy, andx-cache. - The response body is streamed back, which supports the case where the destination returns a file or image. For a 302 the body is empty anyway.
- If the upstream call fails, the handler responds with a 502 Bad gateway.
Key Screens & Components
This feature has no screens — it is entirely server-side code that completes within a single request.
- Route handler (
src/app/c/[token]/route.ts) — handles GET only, runs on the Node runtime, and is forced to be dynamic so it is never cached at build time. - Header allow-list is kept as a constant in the same file so no unintended header leaks through to the user.
- Upstream target:
GET {client-api}/api/c/{token} - Related environment variables:
BASE_API_INTERNAL_URLandPUBLIC_API_URL
Dependencies
- Uses no axios, React Query, or LIFF — just a plain Node-side
fetch, which keeps it lightweight and free of UI dependencies. - Tied to the campaign delivery and tracking design on the worker side, which generates the tokens and embeds these links in outgoing messages.
- Clearly separated in purpose from the click tracking link: that one is a rich-menu link that identifies the user through LIFF first, while this one is an in-message campaign link that works silently with no UI.
Backend Details (Client API)
The real target is GET /api/c/:token, the only endpoint in client-api that does not
return a JSON envelope. Its caller is not client-web but the user's own LINE client /
in-app browser (when they tap the link) or LINE's own image proxy (when rendering an
image inside a message). It therefore has to answer with a 302 redirect or stream raw
image bytes.
The token is AES-256-GCM encrypted and carries the destination URL plus tracking context
inside itself, so a click resolves without reading the database at all. If decryption
fails the answer is 400 "Invalid token", served as text/html rather than JSON.
Statistics recording — "write first, but never wait"
- If the token has not expired, a goroutine is spawned to write
tracking_logdetached from the request context. The reason: campaign links are typically tapped and then abandoned instantly. Tied to the request context, a client disconnect would cancel the statistics write. - The enums differ by token kind: a URL click is stored as
action_type='click',type='uri', while opening an image is stored asaction_type='read',type='asset'.content_type='campaign'in every case. line_uidis NULL when the token identifies no individual user (for example a broadcast token carrying-). That means no per-person trigger by design, not missing data.Referrer-Policy: no-referreris set on every response, so the token-bearing URL never leaks to the destination site through the referrer.
The url kind — dodging nested LIFF windows
- If the token was minted as a liff-entry (meaning the original link lived under
liff.line.me), the backend does not redirect to the absoluteliff.line.meURL. It rewrites it to a same-origin path instead: a URL shaped likehttps://liff.line.me/<liffId>/foo?x=1becomes/foo?x=1. - Why: a
Locationpointing atliff.line.memakes LINE open a second LIFF window, and for ordinary links it leaves the in-app browser behind as a blank window the user cannot close. - Legacy links that are not liff-entry still redirect as before, so features that require
a LIFF context (the camera, for instance) keep working. URLs that are not on
liff.line.me, or that have no sub-path after the liffId, use the original URL.
The asset kind — image proxying
- SSRF guard — before fetching anything, the service checks the origin URL does not
point at the internal network. It blocks
localhostand its subdomains, IPv4 reserved ranges (10.x,127.x,0.x,169.254.x— the cloud metadata endpoint —192.168.x,172.16–31.x), and IPv6 loopback / link-local / unique-local addresses. A URL that cannot be parsed is treated as unsafe rather than allowed through, giving 403"Blocked: private URL". - Cache lookup before fetching — the key is a hash of the origin URL, storing both
the content type and the file bytes. A hit answers with
X-Cache: HITandCache-Control: public, max-age=86400. - On a miss it fetches the origin with a 15-second timeout. A non-2xx response gives
502
"Failed to fetch asset". - The read cap is 50MB; exceeding it gives 502
"Asset too large". - Only files up to 2MB are cached, for 24 hours, fire-and-forget — larger files still stream through, they just do not consume Redis.
- Response headers are the origin's
Content-Type(defaulting toapplication/octet-stream),Content-Length,Cache-Control: public, max-age=86400, andX-Cache: MISS— exactly the set the client-web proxy allow-lists.