Skip to main content

Campaign Link & Image Proxy

Overview

This is the one endpoint in the service that does not return a JSON envelope, because its caller is not client-web. It is either the LINE client / in-app browser itself, when a user taps a link, or LINE's image proxy, when a message renders an image. It therefore answers with a 302 redirect or by streaming raw image bytes.

The token is encrypted and carries both the destination and the tracking context inside it, so a click resolves without touching the database at all. The only write is an asynchronous, best-effort tracking_log entry.

Business Flow

GET /api/c/:token

  1. Decrypt the token with the AES-256-GCM keyring (campaigntoken.Ring). A failure returns 400 "Invalid token" as text/html.
  2. Check expiry against payload.E.
  3. Record first, but do not wait. When the token is still valid, a goroutine writes the tracking_log entry with a context detached from the request, so a client that disconnects does not cancel the write.
    • The enums differ by action: a URL click uses action_type='click' with type='uri', while opening an image uses action_type='read' with type='asset'.
    • content_type is always campaign. line_uid is NULL when u is empty or "-" — a broadcast token, which deliberately means there is no per-user trigger.
    • raw_data is passed as a string, for the same reason as in tracking-redirect: the pgx simple protocol combined with a jsonb column.
  4. The Referrer-Policy: no-referrer header is set in every case.
  5. Type url returns a 302.
    • When the token was minted as a liff-entry (payload.F, meaning the tap will open a LIFF window because the link sits under liff.line.me), the redirect targets a same-origin path rather than the absolute liff.line.me URL. liffRelativePath rewrites https://liff.line.me/{liffId}/foo?x=1 into /foo?x=1.
    • The reason: 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 an empty window the user cannot close.
    • Older links that are not liff-entry still redirect as before, so LIFF capabilities such as camera access are not lost.
    • A URL not on liff.line.me, or one with no sub-path after the liffId, is used unchanged.
  6. Type asset proxies the image through streamAsset.
    • The SSRF guard (isPrivateURL) blocks localhost and its subdomains, reserved IPv4 ranges (10.x, 127.x, 0.x, 169.254.x — cloud metadata — 192.168.x, 172.16–31.x), and IPv6 ::1, fd*, fe80*. A URL that fails to parse is treated as unsafe and returns 403 "Blocked: private URL".
    • Cache is checked first, keyed as CAMPAIGN_ASSET:{sha256(originURL)} and stored in the form "{contentType}|{base64}". A hit responds with X-Cache: HIT and Cache-Control: public, max-age=86400.
    • On a miss, the origin is fetched with a 15-second timeout; a non-2xx response returns 502 "Failed to fetch asset".
    • At most 50MB is read (assetMaxBytes); anything larger returns 502 "Asset too large".
    • Only files up to 2MB (assetCacheMaxBytes) are cached, with a 24-hour TTL, fire-and-forget.
    • The response carries the origin's Content-Type (defaulting to application/octet-stream), Content-Length, Cache-Control: public, max-age=86400, and X-Cache: MISS.

Key Files & Functions

ItemValue
RouteGET /api/c/:token
Registerinternal/campaignredirect/register.goRegister(r, deps) (the /c group)
Handlerinternal/campaignredirect/handler.go(*Handler).Handle, which writes to c.Writer directly then calls c.Abort()
Serviceinternal/campaignredirect/service.goNewService, Handle, recordEvent, streamAsset, liffRelativePath, campaignID, writePlain
SSRFinternal/campaignredirect/ssrf.goisPrivateURL, ipv4Literal
Token ringinternal/campaigntoken/token.goNewRing, Decrypt, Payload
ConstantsassetCacheMaxBytes=2MB, assetCacheTTL=86400, assetFetchTimeout=15s, assetMaxBytes=50MB

Connections to Other Services

  • The tracking_log table (write-only)
  • Redis via internal/cache for the image byte cache, keyed by origin URL
  • The CAMPAIGN_LINK_KEYS config — the keyring shared with tracking-redirect but separated by AAD — plus deps.Config.RedisCache.Namespace and TTL
  • Tokens are minted by cms-api-go when a campaign or rich message is created
  • Corresponding client-web feature: campaign-redirect-proxy, where client-web runs a server-side Route Handler that proxies to this endpoint so links live on a public domain