Skip to content

feat(links): UTM parameters + branded social-preview cards#4264

Open
anvme wants to merge 3 commits into
umami-software:devfrom
anvme:feat/links-utm-params
Open

feat(links): UTM parameters + branded social-preview cards#4264
anvme wants to merge 3 commits into
umami-software:devfrom
anvme:feat/links-utm-params

Conversation

@anvme
Copy link
Copy Markdown

@anvme anvme commented May 8, 2026

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_content per 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-hardened Agent + connect.lookup APIs that aren't reachable through Node's global fetch).

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

anvme added 2 commits May 8, 2026 09:41
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.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

@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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This 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 after() backfill).

  • UTM parameters: five nullable fields added to the link table; appendQueryParams merges them into the redirect target using URLSearchParams.set (safe encoding); form expands on demand.
  • OG preview cards: destination HTML is fetched server-side with an SSRF-hardened undici agent (safeLookup filters DNS results to public IPs only); metadata is written back via per-field updateMany guarded on url + *Manual: false so slow backfills can't clobber newer edits or manual overrides; bot detection gate in the slug route returns an escaped HTML card instead of a 307 for crawlers.
  • Schema / validation: shared schemas.ts centralises utmField, ogTitleField, ogDescriptionField, and ogImageField (used by both create and update routes); ogImageField validates user-supplied image URLs against isPublicHttpUrl to block IP-literal private addresses.

Confidence Score: 5/5

The 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

Filename Overview
src/lib/og.ts New SSRF-hardened OG metadata fetcher using undici with custom DNS lookup guard, body cap, redirect chain validation, and entity decoding. Well-structured with thorough edge-case handling.
src/lib/og-html.ts New server-side HTML renderer for bot OG cards. All user-controlled values are HTML-escaped; CSP + meta robots prevent abuse. No issues found.
src/app/api/links/og-preview/route.ts New live-preview endpoint. Auth-gated and SSRF-safe via fetchOgMetadata internals, but re-defines isHttpUrl locally instead of importing from schemas.ts.
src/app/api/links/schemas.ts New shared Zod schema helpers for UTM and OG fields. Correctly extracted, ogImageField validates against isPublicHttpUrl (IP-literal guard).
src/queries/prisma/link.ts applyOgIntent cleanly classifies intent synchronously; backfillOgMetadata is race-guarded via per-field updateMany with url+flag where-clause. Redis invalidation covers slug rename and delete.
src/app/(collect)/q/[slug]/route.ts Bot-detection path correctly returns OG HTML before analytics recording; UTM parameters merged via appendQueryParams before the 307 redirect for real users.
src/app/(main)/links/LinkEditForm.tsx OgUrlWatcher correctly debounces, aborts stale fetches, and avoids promoting auto values to manual on edit. dirtyFieldsRef gate prevents submitting untouched empty OG fields.
prisma/migrations/22_add_link_og_metadata/migration.sql Additive nullable OG metadata columns plus three BOOLEAN columns with DEFAULT false. Safe, no backfill needed since all rows start in auto mode.

Sequence Diagram

sequenceDiagram
    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)"
Loading

Reviews (2): Last reviewed commit: "fix(links): defer OG fetch via after(), ..." | Re-trigger Greptile

Comment thread src/queries/prisma/link.ts
Comment thread src/app/api/links/route.ts Outdated
Comment thread src/app/(collect)/q/[slug]/route.ts
Comment thread src/lib/og.ts
…_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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant