Skip to content

Share (link previews)

Purpose

The share domain serves server-rendered Open Graph pages so a shared Tomoda link unfurls as a rich preview card in iMessage, WhatsApp, Slack, Discord, and the like. The app is a JavaScript SPA that crawlers cannot execute, so the tags must be rendered without JS: an edge worker sends only crawler traffic to these endpoints and redirects humans to the app.

Mental model

tomoda.life/events/123  ──edge worker──▶  bot?  ──▶  /api/v1/og/events/123  (this domain)
                                          human ──▶  the app (SPA / native via universal links)

The public host (config.Public.BaseURL) is what users share; api.* is never a shared link. The worker rewrites tomoda.life/<path> to /api/v1/og/<path> for crawlers. Each renderer builds a normalized OGCard{Type,Title,Description, Image,URL} and executes one html/template (og.html), which contextually escapes every field.

HTTP endpoints

Public and unauthenticated. Registered in backend/internal/services/share/routes.go, mounted at /api/v1/og in backend/internal/wiring/router.go.

Paths mirror the app's public routes so a crawler and a human resolve the same URL (the /u/{id} screen accepts a UUID handle).

Route Card
GET /og/events/{id} event title, Hosted by … · date · place, cover image
GET /og/u/{id} display name, @handle + bio, brand default image
GET /og/m/{id} {user} on Tomoda, caption, asset image
GET /og/places/{id} place name, category · city, first photo
GET /og/img/{type}/{id} 302 to the entity's real image, or the brand default
GET /og/* (NotFound) generic brand card, so any path still unfurls

A /places/{id} share link lands humans on the discover map for that location (a redirect route to /place-map?type=location&id=…). The legacy GET /api/v1/share/events/{id} (event domain) predates this and is superseded by /og/events/{id}.

Durable images

og:image points at the stable /img/{type}/{id} endpoint, never at the asset directly, because prod asset URLs are CloudFront-signed and expire (30 min) while crawlers cache the HTML for days. On each fetch the image endpoint re-runs the same visibility gate, then 302-redirects to a freshly signed asset URL (or the brand default). The HTML holds a URL that never expires; the bytes come from a fresh signed URL every time, and no bytes are proxied through the backend.

Invariants & gotchas

  • Visibility gates before the template. A missing, private, or expired target returns ErrNotShareable, and the handler renders the generic brand card instead. A private event's title never reaches the HTML, so it cannot leak into an unfurl. Gates: events use EventVisibleTo (anonymous viewer); users are shown only when PassportVisibility is public; moments require public visibility, not soft-deleted, and journaled or not-yet-expired.
  • Always HTTP 200. Even the not-found path returns 200 with the brand card, so a crawler never records a failure for a real link.
  • The image endpoint re-applies the gate. /img/{type}/{id} builds the same card and only redirects to the real asset when it passes; a private, expired, or missing target redirects to the brand default (indistinguishable from each other), so a gated asset is never served and its existence is not confirmed.
  • Images fall back to Public.BaseURL + /og-default.png when the entity has none.
  • Reads via inbound ports. The slice depends on EventReader, UserReader, ProfileReader, LocationReader, MomentReader (in deps.go), each bound to the owning domain's service in internal/wiring/providers.go. It owns no store.
  • Crawlers cache aggressively; use each platform's re-scrape debugger when testing tag changes.

Configuration

  • Public.BaseURL is the share host, the canonical app host so the edge worker can run (worker routes only fire on proxied Cloudflare hosts): https://tomoda.life (prod) and https://app-dev.tomoda.life (dev), set in config.{prod,dev}.yaml. It drives og:url, the stable og:image, and the frontend share links (EXPO_PUBLIC_WEB_URL). app.tomoda.life redirects to the apex.
  • The og-default card is frontend/public/og-default.png.
  • Wiring for the DI + route composition.
  • The edge worker (crawler routing, universal-links .well-known files) lives in the devops repo's Cloudflare infrastructure.