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
- Decrypt the token with the AES-256-GCM keyring (
campaigntoken.Ring). A failure returns400 "Invalid token"as text/html. - Check expiry against
payload.E. - Record first, but do not wait. When the token is still valid, a goroutine writes the
tracking_logentry 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'withtype='uri', while opening an image usesaction_type='read'withtype='asset'. content_typeis alwayscampaign.line_uidis NULL whenuis empty or"-"— a broadcast token, which deliberately means there is no per-user trigger.raw_datais passed as a string, for the same reason as in tracking-redirect: the pgx simple protocol combined with a jsonb column.
- The enums differ by action: a URL click uses
- The
Referrer-Policy: no-referrerheader is set in every case. - Type
urlreturns 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 underliff.line.me), the redirect targets a same-origin path rather than the absoluteliff.line.meURL.liffRelativePathrewriteshttps://liff.line.me/{liffId}/foo?x=1into/foo?x=1. - The reason: a
Locationpointing atliff.line.memakes 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.
- When the token was minted as a liff-entry (
- Type
assetproxies the image throughstreamAsset.- The SSRF guard (
isPrivateURL) blockslocalhostand 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 returns403 "Blocked: private URL". - Cache is checked first, keyed as
CAMPAIGN_ASSET:{sha256(originURL)}and stored in the form"{contentType}|{base64}". A hit responds withX-Cache: HITandCache-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 returns502 "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 toapplication/octet-stream),Content-Length,Cache-Control: public, max-age=86400, andX-Cache: MISS.
- The SSRF guard (
Key Files & Functions
| Item | Value |
|---|---|
| Route | GET /api/c/:token |
| Register | internal/campaignredirect/register.go → Register(r, deps) (the /c group) |
| Handler | internal/campaignredirect/handler.go → (*Handler).Handle, which writes to c.Writer directly then calls 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 |
| Constants | assetCacheMaxBytes=2MB, assetCacheTTL=86400, assetFetchTimeout=15s, assetMaxBytes=50MB |
Connections to Other Services
- The
tracking_logtable (write-only) - Redis via
internal/cachefor the image byte cache, keyed by origin URL - The
CAMPAIGN_LINK_KEYSconfig — the keyring shared with tracking-redirect but separated by AAD — plusdeps.Config.RedisCache.NamespaceandTTL - 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