feat(links): UTM parameters + branded social-preview cards#4264
Conversation
Lets users attach utm_source / utm_medium / utm_campaign / utm_term / utm_content to a short link as structured fields instead of baking them into the destination URL by hand. UTMs are merged into link.url at redirect time, preserving any pre-existing query string and overwriting conflicting UTMs (the structured field is the source of truth). The form's UTM section is collapsed by default and auto-expands when editing a link that already has any UTM value set. An empty-string-to- null Zod transform keeps the DB free of meaningless empty strings. Adds Redis cache invalidation in createLink/updateLink/deleteLink so edits to url/slug/UTMs take effect immediately instead of waiting for the 24h redirect-cache TTL — including a defensive del in createLink to guard against slug-reuse-after-hard-delete poisoning the cache.
When a Umami short link is shared on Twitter/X, Facebook, LinkedIn, Slack, Discord, WhatsApp, Telegram, or iMessage, the platform's crawler now receives an Open Graph card from Umami's own domain instead of being 307'd straight through to the destination. The card content is auto-derived from the destination URL's OG tags at create/update time, so existing-link UX stays set-and-forget. Humans keep the existing 307 redirect path with UTMs merged. - 6 new Link columns: ogTitle / ogDescription / ogImage plus per-field *Manual booleans tracking whether each value was user-set (preserved across URL changes) or auto-detected (re-fetched on URL change). - src/lib/og.ts: SSRF-hardened OG fetcher built on undici with a custom connect.lookup (closes the DNS-rebinding TOCTOU), an IP-literal pre-check that allows only `unicast` ranges (rejects loopback, private, link-local, NAT64 / 6to4 / teredo transition prefixes, and IPv4-mapped IPv6), manual redirect loop with per-hop revalidation, body/timeout/content-type caps, and entity-decoded regex meta extraction with surrogate guards. - src/lib/og-html.ts: single-line HTML renderer with strict per-response headers (CSP, X-Frame-Options DENY, Referrer-Policy, private/no-store + Vary: User-Agent). Conditional meta tag emission so empty fields produce no broken-image cards. - /q/[slug] route: isbot() branch returns OG HTML; human branch unchanged. - GET /api/links/og-preview: authed live-preview endpoint powering the form's debounced auto-detection. - API schemas (create + update) gain the three OG fields with the '' → null Zod transform plus a public-http(s)-only refinement on ogImage that reuses the same SSRF guard at the API edge. Update route preserves the undefined-vs-null PATCH distinction. - Form: collapsible "Customize preview" section with image preview, AbortController-cancellable live-preview fetch as you type, dirty- field-based submit normalization to avoid spurious clear-to-auto. - LinksTable: minmax(0, 1fr) on flex columns so long destinations no longer blow out row width. - 5 new i18n keys added to en-US and translated into all 51 other locales at correct alphabetical positions (each locale diff is exactly +5 lines). - New direct dep: undici@^8.2.0 for the Agent + connect.lookup APIs. Existing links are non-destructively migrated; old rows fall back to a basic preview card (name as title, "Redirects to <host>" as description, og:image omitted → twitter:card downgrades to summary). Future edits organically backfill via the URL-change re-parse path.
|
@anvme is attempting to deploy a commit to the Umami Software Team on Vercel. A member of the Team first needs to authorize it. |
Greptile SummaryThis PR adds two features to the short-links system: UTM parameter injection (merged into the destination URL at redirect time) and branded social-preview cards (an OG HTML page is served to crawlers detected by user-agent, with metadata auto-fetched from the destination at create/update time via a deferred
Confidence Score: 5/5The change is safe to merge. SSRF protection is layered (IP-literal validation at schema time + DNS-level filtering in safeLookup + undici ssrfAgent), HTML escaping in renderOgHtml is thorough, OG backfill races are guarded, and the two migrations are purely additive nullable columns. All new server-side fetch paths go through the undici ssrfAgent with safeLookup filtering, the HTML card renderer escapes every user-controlled value, backfill writes are race-guarded with per-field updateMany conditions, and the two database migrations add only nullable columns with safe defaults. The two observations raised are minor code-hygiene points that do not affect runtime correctness or security. No files require special attention. The og-preview route has a local isHttpUrl copy that should be replaced with the shared import from schemas.ts, and package.json would benefit from an explicit engines.node field to document the undici >=22.19.0 constraint, but neither blocks merging. Important Files Changed
Sequence DiagramsequenceDiagram
participant Client
participant SlugRoute as /q/[slug]
participant APIRoute as /api/links
participant PrismaLink as link.ts (Prisma)
participant OgFetcher as og.ts (undici)
participant Destination
Client->>SlugRoute: "GET /q/{slug}"
SlugRoute->>PrismaLink: findLink(slug)
PrismaLink-->>SlugRoute: link row
alt "Bot (isbot=true)"
SlugRoute->>SlugRoute: renderOgHtml(link, baseOrigin, target)
SlugRoute-->>Client: 200 OG HTML (meta refresh to target)
else Real user
SlugRoute->>SlugRoute: appendQueryParams(url, UTM fields)
SlugRoute-->>Client: 307 Redirect to target+UTM
end
Note over APIRoute,OgFetcher: On link create/update
Client->>APIRoute: POST /api/links
APIRoute->>PrismaLink: createLink / updateLink
PrismaLink->>PrismaLink: applyOgIntent (sync, sets Manual flags)
PrismaLink-->>APIRoute: result + before snapshot
APIRoute-->>Client: 200 (OG fields may be null initially)
APIRoute->>PrismaLink: after() backfillOgMetadata
PrismaLink->>OgFetcher: fetchOgMetadata(url)
OgFetcher->>OgFetcher: safeLookup (DNS to public IPs only)
OgFetcher->>Destination: GET (undici, 5s timeout, 1MB cap)
Destination-->>OgFetcher: HTML
OgFetcher-->>PrismaLink: title, description, image
PrismaLink->>PrismaLink: "updateMany (guarded on url + Manual=false)"
Reviews (2): Last reviewed commit: "fix(links): defer OG fetch via after(), ..." | Re-trigger Greptile |
…_HOPS Address review feedback on umami-software#4264: - Split applyOgFields into sync applyOgIntent + async backfillOgMetadata. Routes now wrap the create/update with next/server after() so the 5s-bounded OG fetch no longer blocks the response. backfillOgMetadata uses per-field updateMany guarded on url + *Manual:false + deletedAt:null so a slow backfill can't clobber a newer URL, manual override, or soft-deleted row. applyOgIntent also nulls auto-managed OG fields up front on url change so the prior URL's metadata isn't shown in the gap. - Extract duplicated Zod helpers (utmField, ogTitleField, ogDescriptionField, ogImageField, isHttpUrl, isPublicHttpUrl) to src/app/api/links/schemas.ts. - Rename MAX_REDIRECTS to MAX_HOPS for accuracy (semantics unchanged). API semantics note: POST /api/links and POST /api/links/:linkId now return auto OG fields as null initially; values populate within ~3s. UI live-preview already runs client-side via /api/links/og-preview.
Two new features for short links, committed separately so they review independently. Full rationale and design notes are in the commit messages.
1. UTM parameters (
b3b65fc)Optional
utm_source/utm_medium/utm_campaign/utm_term/utm_contentper link. Merged into the destination URL on click, preserving existing query strings; conflicting UTM keys are overwritten by the link-level value.2. Branded social-preview cards (
0942a22)When a short link is shared on X, Facebook, LinkedIn, Slack, Discord, WhatsApp, Telegram, or iMessage, the platform crawler now receives an OG card from the Umami domain instead of being 307'd straight to the destination. Card content is auto-derived from the destination's OG tags at create/update time; per-field user override via a new "Customize preview" form section.
New direct dep:
undici@^8.2.0(for the SSRF-hardenedAgent+connect.lookupAPIs that aren't reachable through Node's globalfetch).Niche-language i18n strings (km-KH, my-MM, fo-FO, mn-MN, si-LK, ta-IN, bn-BD) are best-effort machine-assisted; happy to defer to native speakers.
Docs companion: umami-software/docs#24