Skip to main content

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

  1. The user taps a /c/{token} link embedded in a campaign message sent by the worker service.
  2. The route handler reads the internal API base URL from BASE_API_INTERNAL_URL, falling back to PUBLIC_API_URL. If neither is configured it responds with a 500 and a message stating that the redirect service is not configured.
  3. It normalizes the base URL by stripping any trailing / and any trailing /api segment, then assembles the upstream URL with the token URL-encoded.
  4. It calls upstream with redirect: 'manual' and cache: '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.
  5. Only allow-listed headers are copied through: content-type, content-length, cache-control, location, referrer-policy, and x-cache.
  6. 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.
  7. 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_URL and PUBLIC_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_log detached 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 as action_type='read', type='asset'. content_type='campaign' in every case.
  • line_uid is 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-referrer is 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 absolute liff.line.me URL. It rewrites it to a same-origin path instead: a URL shaped like https://liff.line.me/<liffId>/foo?x=1 becomes /foo?x=1.
  • Why: a Location pointing at liff.line.me makes 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 localhost and 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: HIT and Cache-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 to application/octet-stream), Content-Length, Cache-Control: public, max-age=86400, and X-Cache: MISS — exactly the set the client-web proxy allow-lists.