Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ DEEPLINK_API_KEY=
# DEEPLINK_ANDROID_STORE_URL=https://play.google.com/store/apps/details?id=com.example.app
# DEEPLINK_IOS_STORE_URL=https://apps.apple.com/us/app/example-app/id1234567890
# DEEPLINK_WEB_FALLBACK_URL=https://example.com

# Preview metadata defaults emitted on every preview page.
# DEEPLINK_SITE_NAME=Example
# DEEPLINK_LOCALE=en_US
# DEEPLINK_TWITTER_SITE=@example
# DEEPLINK_FEDIVERSE_CREATOR=@user@instance.tld
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
.env
*.xml

# Personal notes / scratch files
local.md
seed.sh

# Build artifacts
/deeplink
/cmd/deeplink/deeplink
Expand Down
71 changes: 67 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,83 @@ Environment variables for `cmd/deeplink`:
| `DEEPLINK_CLICK_BUFFER_SIZE` | `1024` | Async click event buffer capacity |
| `DEEPLINK_CLICK_FLUSH_INTERVAL` | `1s` | How often buffered clicks are flushed to the store |
| `DEEPLINK_API_KEY` | | Protect mutating endpoints (`Authorization: Bearer <key>` or `X-API-Key: <key>`) |
| `DEEPLINK_SITE_NAME` | | `og:site_name` on every preview |
| `DEEPLINK_LOCALE` | `en_US` | Default `og:locale` (per-link `locale` overrides) |
| `DEEPLINK_TWITTER_SITE` | | `twitter:site` (e.g. `@example`) |
| `DEEPLINK_FEDIVERSE_CREATOR` | | `fediverse:creator` (e.g. `@user@instance.tld`) |

## Templates

The default templates in `templates/default/` use these fields from `Link`:

| Field | Template variable | Used for |
| --- | --- | --- |
| URL | `{{.URL}}` | Redirect target, og:url |
| Title | `{{.Title}}` | Page title, og:title |
| Description | `{{.Description}}` | og:description |
| ImageURL | `{{.ImageURL}}` | og:image |
| URL | `{{.URL}}` | Redirect target |
| Title | `{{.Title}}` | Page title, og:title, twitter:title |
| Description | `{{.Description}}` | og:description, twitter:description |
| ImageURL | `{{.ImageURL}}` | og:image, twitter:image |
| ImageWidth | `{{.ImageWidth}}` | og:image:width |
| ImageHeight | `{{.ImageHeight}}` | og:image:height |
| ImageAlt | `{{.ImageAlt}}` | og:image:alt, twitter:image:alt |
| OGType | `{{.OGType}}` | og:type (defaults to `website`) |
| Locale | `{{.Locale}}` | og:locale (per-link override) |
| Lang | `{{.Lang}}` | `<html lang>` (derived from Locale) |
| CreatedAt | `{{.CreatedAt}}` | article:published_time |
| UpdatedAt | `{{.UpdatedAt}}` | og:updated_time, article:modified_time |
| ShortURL | `{{.ShortURL}}` | canonical link, og:url |

To customize, copy `templates/default/` and set `TemplateDir` in config.

## Link preview metadata

Preview pages emit standard Open Graph, Twitter Card, and fediverse meta
tags consumed by every platform that scrapes link previews. Every tag is
gated on its source value: empty fields are not emitted, so scrapers never
see `content=""` warnings.

### Per-link fields (`Link`)

| Field | Effect |
| --- | --- |
| `Title` | `<title>`, `og:title`, `twitter:title` |
| `Description` | `description`, `og:description`, `twitter:description` |
| `ImageURL` | `og:image`, `twitter:image`. PNG/JPG/WebP only (SVG fails most scrapers); 1200x630 recommended |
| `ImageWidth` / `ImageHeight` | `og:image:width` / `og:image:height` |
| `ImageAlt` | `og:image:alt`, `twitter:image:alt` |
| `OGType` | `og:type` (defaults to `website`). Setting `article` also emits `article:published_time` and `article:modified_time` |
| `Locale` | `og:locale`. Falls back to `Config.Locale` |
| `UpdatedAt` | `og:updated_time`. Set automatically on create |

### Service-wide fields (`Config`)

| Field | Effect |
| --- | --- |
| `SiteName` | `og:site_name` |
| `Locale` | Default `og:locale` when `Link.Locale` is empty |
| `TwitterSite` | `twitter:site` (e.g. `@example`) |
| `FediverseCreator` | `fediverse:creator` (e.g. `@user@instance.tld`) |

### Example payload

```bash
curl -X POST http://localhost:8090/shorten \
-H 'Content-Type: application/json' \
-d '{
"type": "redirect",
"url": "https://example.com/posts/launch",
"title": "We just launched",
"description": "What is new in v2",
"image_url": "https://cdn.example.com/og/launch.png",
"image_width": 1200,
"image_height": 630,
"image_alt": "v2 launch cover",
"og_type": "article"
}'
```

Preview pages also emit `<meta name="robots" content="noindex,follow">` so
short links do not compete with the destination URL in search rankings.

## Development

```bash
Expand Down
4 changes: 4 additions & 0 deletions cmd/deeplink/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ func run() error {
AndroidStoreURL: os.Getenv("DEEPLINK_ANDROID_STORE_URL"),
IOSStoreURL: os.Getenv("DEEPLINK_IOS_STORE_URL"),
WebFallbackURL: os.Getenv("DEEPLINK_WEB_FALLBACK_URL"),
SiteName: os.Getenv("DEEPLINK_SITE_NAME"),
Locale: os.Getenv("DEEPLINK_LOCALE"),
TwitterSite: os.Getenv("DEEPLINK_TWITTER_SITE"),
FediverseCreator: os.Getenv("DEEPLINK_FEDIVERSE_CREATOR"),
}

service, err := deeplink.New(cfg)
Expand Down
21 changes: 21 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import (
"time"
)

// defaultLocale is the og:locale fallback when neither Link.Locale nor
// Config.Locale is set. en_US is the most widely-recognized OG locale value.
const defaultLocale = "en_US"

// Config configures a deeplink [Service].
type Config struct {
// BaseURL is the prefix for generated short links
Expand Down Expand Up @@ -52,6 +56,20 @@ type Config struct {
AndroidStoreURL string
IOSStoreURL string
WebFallbackURL string

// SiteName is emitted as og:site_name on every preview page.
SiteName string

// Locale is the default og:locale value (e.g. "en_US"). A non-empty
// Link.Locale overrides it. Defaults to "en_US" when empty.
Locale string

// TwitterSite is the @handle emitted as twitter:site (e.g. "@example").
TwitterSite string

// FediverseCreator is the fediverse account emitted as
// fediverse:creator (e.g. "@user@instance.tld").
FediverseCreator string
}

func (c *Config) defaults() {
Expand All @@ -70,6 +88,9 @@ func (c *Config) defaults() {
if c.ClickFlushInterval == 0 {
c.ClickFlushInterval = time.Second
}
if c.Locale == "" {
c.Locale = defaultLocale
}
if c.HTTPClient == nil {
c.HTTPClient = &http.Client{
Transport: &http.Transport{
Expand Down
16 changes: 16 additions & 0 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,26 @@ type Link struct {
// Description for the OG preview page.
Description string `json:"description,omitempty"`
// ImageURL for the OG preview image.
// PNG/JPG/WebP only (SVG fails most scrapers); 1200x630 recommended.
ImageURL string `json:"image_url,omitempty"`
// ImageWidth is the og:image:width hint in pixels.
ImageWidth int `json:"image_width,omitempty"`
// ImageHeight is the og:image:height hint in pixels.
ImageHeight int `json:"image_height,omitempty"`
// ImageAlt is the alt text for og:image and twitter:image.
ImageAlt string `json:"image_alt,omitempty"`
// OGType is the og:type value (e.g. "website", "article", "profile").
// Empty defaults to "website" in the template. Setting "article" also
// emits article:published_time and article:modified_time tags.
OGType string `json:"og_type,omitempty"`
// Locale overrides the per-link og:locale (e.g. "en_US", "fr_FR").
// Empty falls back to Config.Locale.
Locale string `json:"locale,omitempty"`

// CreatedAt is an RFC 3339 timestamp. Set by the service.
CreatedAt string `json:"created_at,omitempty"`
// UpdatedAt is an RFC 3339 timestamp. Set by the service.
UpdatedAt string `json:"updated_at,omitempty"`

// Metadata holds processor-specific data.
Metadata map[string]any `json:"metadata,omitempty"`
Expand Down
82 changes: 82 additions & 0 deletions preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package deeplink

import (
"net/http"
"strings"
)

// previewCacheControl is the Cache-Control header for rendered preview pages.
// Short max-age lets caller metadata edits propagate through HTTP caches
// within minutes; the SWR window keeps responses fast in the meantime.
const previewCacheControl = "public, max-age=300, stale-while-revalidate=600"

// previewView is the data passed to the default templates. It embeds *Link
// so existing template fields keep working, and adds config-derived values
// the templates need but the Link itself does not carry.
//
// OGType, Locale, and Lang shadow the corresponding embedded Link fields on
// purpose: the wrapper applies the Config.Locale fallback, lowercases OGType
// so the template's exact-string comparison stays consistent, and derives
// Lang from Locale.
//
// Custom processors that implement Previewer and return non-nil data bypass
// this wrapper; their data is responsible for the fields its template uses.
type previewView struct {
*Link
ShortURL string
SiteName string
Locale string
Lang string
OGType string
TwitterSite string
FediverseCreator string
}

// buildPreviewData wraps a Link with the config-derived rendering data the
// default templates expect. Custom Previewer output, when non-nil, is passed
// through unchanged.
func (s *Service) buildPreviewData(payload *Link) any {
processor := s.registry.Get(payload.Type)
if p, ok := processor.(Previewer); ok {
if data := p.Preview(payload); data != nil {
return data
}
}

locale := payload.Locale
if locale == "" {
locale = s.config.Locale
}

return previewView{
Link: payload,
ShortURL: s.config.BaseURL + payload.ShortID,
SiteName: s.config.SiteName,
Locale: locale,
Lang: langFromLocale(locale),
OGType: strings.ToLower(strings.TrimSpace(payload.OGType)),
TwitterSite: s.config.TwitterSite,
FediverseCreator: s.config.FediverseCreator,
}
}

// langFromLocale extracts the BCP 47 language subtag from an OG locale value
// (e.g. "en_US" -> "en", "fr-FR" -> "fr"). Returns "en" as a safe fallback.
func langFromLocale(locale string) string {
if locale == "" {
return "en"
}
if i := strings.IndexAny(locale, "_-"); i > 0 {
return strings.ToLower(locale[:i])
}
return strings.ToLower(locale)
}

// writePreviewHTML writes a rendered preview page with the shared
// Content-Type and Cache-Control headers used by both preview handlers.
func writePreviewHTML(w http.ResponseWriter, body []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", previewCacheControl)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
22 changes: 3 additions & 19 deletions server.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,7 @@ func (s *Service) handlePreview(w http.ResponseWriter, r *http.Request) {
return
}

w.Header().Set("Content-Type", "text/html")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
writePreviewHTML(w, buf.Bytes())
s.config.Logger.Info("preview rendered", "shortID", shortID, "type", payload.Type, "duration", time.Since(start))
}

Expand All @@ -117,7 +114,6 @@ func (s *Service) handlePreview(w http.ResponseWriter, r *http.Request) {
// flow where the user should tap to continue.
func (s *Service) handleStaticPreview(w http.ResponseWriter, r *http.Request) {
start := time.Now()
w.Header().Set("Content-Type", "text/html")

shortID := r.PathValue("shortID")

Expand All @@ -142,8 +138,7 @@ func (s *Service) handleStaticPreview(w http.ResponseWriter, r *http.Request) {
return
}

w.WriteHeader(http.StatusOK)
_, _ = w.Write(buf.Bytes())
writePreviewHTML(w, buf.Bytes())
s.config.Logger.Info("preview rendered (no redirect)", "shortID", shortID, "type", payload.Type, "duration", time.Since(start))
}

Expand Down Expand Up @@ -257,16 +252,6 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("OK"))
}

func (s *Service) buildPreviewData(payload *Link) any {
processor := s.registry.Get(payload.Type)
if p, ok := processor.(Previewer); ok {
if data := p.Preview(payload); data != nil {
return data
}
}
return payload
}

func (s *Service) withCORS(h http.HandlerFunc) http.HandlerFunc {
allowed := make(map[string]bool, len(s.config.AllowedOrigins))
for _, o := range s.config.AllowedOrigins {
Expand All @@ -289,8 +274,7 @@ func (s *Service) withCORS(h http.HandlerFunc) http.HandlerFunc {
}

func (s *Service) respondError(w http.ResponseWriter, err error) {
var appErr *Error
if errors.As(err, &appErr) {
if appErr, ok := errors.AsType[*Error](err); ok {
s.config.Logger.Error("request error", "error", appErr, "status", appErr.Code)
http.Error(w, appErr.Message, appErr.Code)
return
Expand Down
Loading
Loading