diff --git a/.env.example b/.env.example index 3ba0713..99ab4b1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index e368b4e..e3f789f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ .env *.xml +# Personal notes / scratch files +local.md +seed.sh + # Build artifacts /deeplink /cmd/deeplink/deeplink diff --git a/README.md b/README.md index e82f7e2..f591796 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ 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 ` or `X-API-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 @@ -124,13 +128,72 @@ 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}}` | `` (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` | ``, `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 diff --git a/cmd/deeplink/main.go b/cmd/deeplink/main.go index 244bcac..0086eed 100644 --- a/cmd/deeplink/main.go +++ b/cmd/deeplink/main.go @@ -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) diff --git a/config.go b/config.go index 2798239..8437c97 100644 --- a/config.go +++ b/config.go @@ -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 @@ -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() { @@ -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{ diff --git a/model.go b/model.go index ea3b226..e12fbce 100644 --- a/model.go +++ b/model.go @@ -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"` diff --git a/preview.go b/preview.go new file mode 100644 index 0000000..b7d6439 --- /dev/null +++ b/preview.go @@ -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) +} diff --git a/server.go b/server.go index 31ce460..4e1cc72 100644 --- a/server.go +++ b/server.go @@ -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)) } @@ -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") @@ -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)) } @@ -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 { @@ -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 diff --git a/service_test.go b/service_test.go index 0b9e490..00d9d27 100644 --- a/service_test.go +++ b/service_test.go @@ -13,6 +13,9 @@ import ( "testing" ) +// testBaseURL is the canonical base URL used across preview/handler tests. +const testBaseURL = "https://link.test/" + type testProcessor struct{} func (testProcessor) Type() string { return "basic" } @@ -47,10 +50,9 @@ func (testProcessor) Process(_ context.Context, payload *Link) error { func TestHandlerGenerateAndPreviewRedirect(t *testing.T) { t.Parallel() - baseURL := "https://link.test/" store := NewMemoryStore() service, err := New(Config{ - BaseURL: baseURL, + BaseURL: testBaseURL, Store: store, }) if err != nil { @@ -81,9 +83,9 @@ func TestHandlerGenerateAndPreviewRedirect(t *testing.T) { } shortURL := response["short_url"] - shortID := strings.TrimPrefix(shortURL, baseURL) + shortID := strings.TrimPrefix(shortURL, testBaseURL) if shortID == shortURL || shortID == "" { - t.Fatalf("expected short URL with base %q, got %q", baseURL, shortURL) + t.Fatalf("expected short URL with base %q, got %q", testBaseURL, shortURL) } stored, err := store.Get(context.Background(), shortID) @@ -134,9 +136,8 @@ func TestNewLoadsTemplates(t *testing.T) { t.Fatalf("write preview template: %v", err) } - baseURL := "https://link.test/" service, err := New(Config{ - BaseURL: baseURL, + BaseURL: testBaseURL, Store: NewMemoryStore(), TemplateDir: dir, }) @@ -162,7 +163,7 @@ func TestNewLoadsTemplates(t *testing.T) { t.Fatalf("decode generate response: %v", err) } - shortID := strings.TrimPrefix(response["short_url"], baseURL) + shortID := strings.TrimPrefix(response["short_url"], testBaseURL) req = httptest.NewRequest(http.MethodGet, "/"+shortID, nil) rec = httptest.NewRecorder() service.Handler().ServeHTTP(rec, req) @@ -174,3 +175,434 @@ func TestNewLoadsTemplates(t *testing.T) { t.Fatalf("preview body = %q, want rendered title", got) } } + +// newPreviewService builds a Service against the real default templates with +// RedirectProcessor registered. +func newPreviewService(t *testing.T, cfg Config) *Service { + t.Helper() + if cfg.BaseURL == "" { + cfg.BaseURL = testBaseURL + } + if cfg.Store == nil { + cfg.Store = NewMemoryStore() + } + if cfg.TemplateDir == "" { + cfg.TemplateDir = "templates/default" + } + service, err := New(cfg) + if err != nil { + t.Fatalf("New() error = %v", err) + } + service.Register(RedirectProcessor{}) + t.Cleanup(func() { _ = service.Close() }) + return service +} + +// createAndFetch posts a Link and GETs the preview at getPathPrefix+shortID +// ("/" for handlePreview, "/preview/" for handleStaticPreview). +func createAndFetch(t *testing.T, service *Service, payloadJSON, getPathPrefix string) (*httptest.ResponseRecorder, string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/shorten", strings.NewReader(payloadJSON)) + rec := httptest.NewRecorder() + service.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create status = %d; body = %s", rec.Code, rec.Body.String()) + } + + var resp map[string]string + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode create response: %v", err) + } + shortID := strings.TrimPrefix(resp["short_url"], service.config.BaseURL) + + req = httptest.NewRequest(http.MethodGet, getPathPrefix+shortID, nil) + rec = httptest.NewRecorder() + service.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("preview status = %d; body = %s", rec.Code, rec.Body.String()) + } + return rec, shortID +} + +func TestPreviewMetaTags_AllFields(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{ + SiteName: "Example", + Locale: "fr_FR", + TwitterSite: "@example", + FediverseCreator: "@example@instance.tld", + }) + + payload := `{ + "type": "redirect", + "url": "https://example.com/post", + "title": "Hello", + "description": "World", + "image_url": "https://cdn.example.com/img.png", + "image_width": 1200, + "image_height": 630, + "image_alt": "Cover image", + "og_type": "website", + "locale": "en_GB" + }` + + rec, shortID := createAndFetch(t, service, payload, "/") + body := rec.Body.String() + + want := []string{ + `<link rel="canonical" href="https://link.test/` + shortID + `">`, + `<meta name="robots" content="noindex,follow">`, + `<meta property="og:type" content="website">`, + `<meta property="og:url" content="https://link.test/` + shortID + `">`, + `<meta property="og:title" content="Hello">`, + `<meta property="og:description" content="World">`, + `<meta property="og:site_name" content="Example">`, + `<meta property="og:locale" content="en_GB">`, + `<meta property="og:image" content="https://cdn.example.com/img.png">`, + `<meta property="og:image:width" content="1200">`, + `<meta property="og:image:height" content="630">`, + `<meta property="og:image:alt" content="Cover image">`, + `<meta property="og:updated_time" content="`, + `<meta name="twitter:card" content="summary_large_image">`, + `<meta name="twitter:title" content="Hello">`, + `<meta name="twitter:description" content="World">`, + `<meta name="twitter:image" content="https://cdn.example.com/img.png">`, + `<meta name="twitter:image:alt" content="Cover image">`, + `<meta name="twitter:site" content="@example">`, + `<meta name="fediverse:creator" content="@example@instance.tld">`, + } + for _, w := range want { + if !strings.Contains(body, w) { + t.Errorf("missing tag: %s\nbody:\n%s", w, body) + } + } +} + +func TestPreviewMetaTags_OmitEmpty(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{Locale: ""}) + + payload := `{"type":"redirect","url":"https://example.com/min"}` + + rec, _ := createAndFetch(t, service, payload, "/") + body := rec.Body.String() + + if strings.Contains(body, `content=""`) { + t.Errorf("found empty content attribute in body:\n%s", body) + } + + forbidden := []string{ + "og:image", + "og:image:width", + "og:image:height", + "og:image:alt", + "twitter:image", + "twitter:image:alt", + "twitter:site", + "fediverse:creator", + "og:site_name", + "article:published_time", + "article:modified_time", + } + for _, f := range forbidden { + if strings.Contains(body, f) { + t.Errorf("unexpected tag/marker %q in body:\n%s", f, body) + } + } + + if !strings.Contains(body, `<meta name="twitter:card" content="summary">`) { + t.Errorf("expected twitter:card=summary fallback") + } +} + +func TestPreviewMetaTags_TwitterCardFallback(t *testing.T) { + t.Parallel() + + t.Run("no image -> summary", func(t *testing.T) { + t.Parallel() + service := newPreviewService(t, Config{}) + rec, _ := createAndFetch(t, service, `{"type":"redirect","url":"https://example.com/a"}`, "/") + if !strings.Contains(rec.Body.String(), `<meta name="twitter:card" content="summary">`) { + t.Errorf("want summary card, body:\n%s", rec.Body.String()) + } + }) + + t.Run("image -> summary_large_image", func(t *testing.T) { + t.Parallel() + service := newPreviewService(t, Config{}) + payload := `{"type":"redirect","url":"https://example.com/b","image_url":"https://cdn.example.com/x.png"}` + rec, _ := createAndFetch(t, service, payload, "/") + if !strings.Contains(rec.Body.String(), `<meta name="twitter:card" content="summary_large_image">`) { + t.Errorf("want summary_large_image, body:\n%s", rec.Body.String()) + } + }) +} + +func TestPreviewMetaTags_Canonical(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{BaseURL: "https://canonical.test/"}) + + rec, shortID := createAndFetch(t, service, `{"type":"redirect","url":"https://example.com/c"}`, "/") + want := `<link rel="canonical" href="https://canonical.test/` + shortID + `">` + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("missing canonical link: %s\nbody:\n%s", want, rec.Body.String()) + } +} + +func TestPreviewMetaTags_RobotsNoindex(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{}) + rec, _ := createAndFetch(t, service, `{"type":"redirect","url":"https://example.com/d"}`, "/") + if !strings.Contains(rec.Body.String(), `<meta name="robots" content="noindex,follow">`) { + t.Errorf("missing robots meta tag, body:\n%s", rec.Body.String()) + } +} + +func TestStaticPreviewCacheControl(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{ + WebFallbackURL: "https://example.com/fallback", + }) + + livePreview, shortID := createAndFetch(t, service, `{"type":"redirect","url":"https://example.com/e"}`, "/") + if got := livePreview.Header().Get("Cache-Control"); got != previewCacheControl { + t.Fatalf("live Cache-Control = %q, want %q", got, previewCacheControl) + } + + req := httptest.NewRequest(http.MethodGet, "/preview/"+shortID, nil) + rec := httptest.NewRecorder() + service.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("static preview status = %d; body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); got != previewCacheControl { + t.Errorf("static Cache-Control = %q, want %q", got, previewCacheControl) + } +} + +func TestPreviewMetaTags_ArticleType(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{ + WebFallbackURL: "https://example.com/fallback", + SiteName: "Example", + }) + + payload := `{ + "type": "redirect", + "url": "https://example.com/article", + "title": "Article title", + "description": "Article body", + "image_url": "https://cdn.example.com/cover.png", + "og_type": "article" + }` + + rec, _ := createAndFetch(t, service, payload, "/preview/") + body := rec.Body.String() + + want := []string{ + `<meta property="og:type" content="article">`, + `<meta property="article:published_time" content="`, + `<meta property="article:modified_time" content="`, + } + for _, w := range want { + if !strings.Contains(body, w) { + t.Errorf("missing article marker %q, body:\n%s", w, body) + } + } +} + +// Mixed-case and padded OGType must still trigger article tags. The +// previewView wrapper normalizes the value so the template's exact-string +// comparison matches. +func TestPreviewMetaTags_OGTypeNormalized(t *testing.T) { + t.Parallel() + + service := newPreviewService(t, Config{ + WebFallbackURL: "https://example.com/fallback", + }) + + payload := `{ + "type": "redirect", + "url": "https://example.com/normalized", + "title": "Mixed case", + "og_type": " Article " + }` + + rec, _ := createAndFetch(t, service, payload, "/preview/") + body := rec.Body.String() + + for _, w := range []string{ + `<meta property="og:type" content="article">`, + `<meta property="article:published_time" content="`, + } { + if !strings.Contains(body, w) { + t.Errorf("missing %q after OGType normalization, body:\n%s", w, body) + } + } +} + +// Locale resolution chain: Link.Locale > Config.Locale > defaultLocale. +// Lang attribute on <html> is derived from the resolved locale. +func TestPreviewMetaTags_LocaleFallback(t *testing.T) { + t.Parallel() + + t.Run("link locale wins", func(t *testing.T) { + t.Parallel() + service := newPreviewService(t, Config{Locale: "fr_FR"}) + rec, _ := createAndFetch(t, service, + `{"type":"redirect","url":"https://example.com/loc1","locale":"de_DE"}`, "/") + body := rec.Body.String() + if !strings.Contains(body, `<meta property="og:locale" content="de_DE">`) { + t.Errorf("link locale should win, body:\n%s", body) + } + if !strings.Contains(body, `<html lang="de"`) { + t.Errorf("expected lang=\"de\" derived from de_DE, body:\n%s", body) + } + }) + + t.Run("config locale fills when link empty", func(t *testing.T) { + t.Parallel() + service := newPreviewService(t, Config{Locale: "fr_FR"}) + rec, _ := createAndFetch(t, service, + `{"type":"redirect","url":"https://example.com/loc2"}`, "/") + body := rec.Body.String() + if !strings.Contains(body, `<meta property="og:locale" content="fr_FR">`) { + t.Errorf("config locale should fill, body:\n%s", body) + } + if !strings.Contains(body, `<html lang="fr"`) { + t.Errorf("expected lang=\"fr\" derived from fr_FR, body:\n%s", body) + } + }) + + t.Run("default locale when both empty", func(t *testing.T) { + t.Parallel() + // Config.Locale empty -> defaults() sets defaultLocale. + service := newPreviewService(t, Config{}) + rec, _ := createAndFetch(t, service, + `{"type":"redirect","url":"https://example.com/loc3"}`, "/") + body := rec.Body.String() + want := `<meta property="og:locale" content="` + defaultLocale + `">` + if !strings.Contains(body, want) { + t.Errorf("expected default locale tag, body:\n%s", body) + } + if !strings.Contains(body, `<html lang="en"`) { + t.Errorf("expected lang=\"en\" derived from default locale, body:\n%s", body) + } + }) +} + +// Empty Title must still produce a valid <title> element so the page is +// HTML5-conformant. We fall back to "Untitled" rather than emitting an +// empty element. +func TestPreviewMetaTags_TitleFallback(t *testing.T) { + t.Parallel() + + service, err := New(Config{ + BaseURL: testBaseURL, + Store: NewMemoryStore(), + TemplateDir: "templates/default", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + service.Register(noTitleProcessor{}) + t.Cleanup(func() { _ = service.Close() }) + + req := httptest.NewRequest(http.MethodPost, "/shorten", + strings.NewReader(`{"type":"no-title","url":"https://example.com/no-title"}`)) + rec := httptest.NewRecorder() + service.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create status = %d", rec.Code) + } + var resp map[string]string + _ = json.NewDecoder(rec.Body).Decode(&resp) + shortID := strings.TrimPrefix(resp["short_url"], testBaseURL) + + req = httptest.NewRequest(http.MethodGet, "/"+shortID, nil) + rec = httptest.NewRecorder() + service.Handler().ServeHTTP(rec, req) + body := rec.Body.String() + + if strings.Contains(body, "<title>") { + t.Errorf("empty emitted, body:\n%s", body) + } + if !strings.Contains(body, "<title>Open link") { + t.Errorf("expected fallback title, body:\n%s", body) + } + // og:title is gated on Link.Title (no fallback) -> must be absent. + if strings.Contains(body, "og:title") { + t.Errorf("og:title should be omitted when Title empty, body:\n%s", body) + } +} + +// Non-article OGType values must emit og:type but skip article:* tags. +func TestPreviewMetaTags_NonArticleOGTypeSkipsArticleTags(t *testing.T) { + t.Parallel() + + for _, ogType := range []string{"profile", "book", "video.movie"} { + t.Run(ogType, func(t *testing.T) { + t.Parallel() + service := newPreviewService(t, Config{ + WebFallbackURL: "https://example.com/fallback", + }) + payload := `{ + "type": "redirect", + "url": "https://example.com/x", + "title": "Sample", + "og_type": "` + ogType + `" + }` + rec, _ := createAndFetch(t, service, payload, "/preview/") + body := rec.Body.String() + + if !strings.Contains(body, ``) { + t.Errorf("missing og:type=%s, body:\n%s", ogType, body) + } + for _, forbidden := range []string{ + "article:published_time", + "article:modified_time", + } { + if strings.Contains(body, forbidden) { + t.Errorf("og:type=%s should not emit %q, body:\n%s", ogType, forbidden, body) + } + } + }) + } +} + +func TestLangFromLocale(t *testing.T) { + t.Parallel() + + cases := []struct { + in, want string + }{ + {"", "en"}, + {"en_US", "en"}, + {"fr-FR", "fr"}, + {"de_DE", "de"}, + {"PT_BR", "pt"}, + {"es", "es"}, + {"ES", "es"}, + } + for _, c := range cases { + if got := langFromLocale(c.in); got != c.want { + t.Errorf("langFromLocale(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +type noTitleProcessor struct{} + +func (noTitleProcessor) Type() string { return "no-title" } +func (noTitleProcessor) Process(_ context.Context, payload *Link) error { + if payload.URL == "" { + return NewError(errors.New("missing url"), http.StatusBadRequest, "url is required") + } + return nil +} diff --git a/templates/default/link.html b/templates/default/link.html index 8ffb6d3..7eda5fd 100644 --- a/templates/default/link.html +++ b/templates/default/link.html @@ -1,21 +1,38 @@ - + - {{.Title}} - - - - - {{if .ImageURL}} - - {{end}} - + + {{with .ShortURL}}{{end}} + {{with .Title}}{{.}}{{else}}Open link{{end}} + {{with .Description}}{{end}} + + + {{with .ShortURL}}{{end}} + {{with .Title}}{{end}} + {{with .Description}}{{end}} + {{with .SiteName}}{{end}} + {{with .Locale}}{{end}} + {{with .UpdatedAt}}{{end}} + {{with .ImageURL}}{{end}} + {{with .ImageWidth}}{{end}} + {{with .ImageHeight}}{{end}} + {{with .ImageAlt}}{{end}} + {{- if eq .OGType "article"}} + {{- with .CreatedAt}} + {{end}} + {{- with .UpdatedAt}} + {{end}} + {{- end}} + - - - {{if .ImageURL}}{{end}} + {{with .Title}}{{end}} + {{with .Description}}{{end}} + {{with .ImageURL}}{{end}} + {{with .ImageAlt}}{{end}} + {{with .TwitterSite}}{{end}} + {{with .FediverseCreator}}{{end}}

Redirecting to {{.URL}}.

diff --git a/templates/default/preview.html b/templates/default/preview.html index 263f286..2948d64 100644 --- a/templates/default/preview.html +++ b/templates/default/preview.html @@ -1,26 +1,43 @@ - + - {{.Title}} - - - - - {{if .ImageURL}} - - {{end}} - + + {{with .ShortURL}}{{end}} + {{with .Title}}{{.}}{{else}}Open link{{end}} + {{with .Description}}{{end}} + + + {{with .ShortURL}}{{end}} + {{with .Title}}{{end}} + {{with .Description}}{{end}} + {{with .SiteName}}{{end}} + {{with .Locale}}{{end}} + {{with .UpdatedAt}}{{end}} + {{with .ImageURL}}{{end}} + {{with .ImageWidth}}{{end}} + {{with .ImageHeight}}{{end}} + {{with .ImageAlt}}{{end}} + {{- if eq .OGType "article"}} + {{- with .CreatedAt}} + {{end}} + {{- with .UpdatedAt}} + {{end}} + {{- end}} + - - - {{if .ImageURL}}{{end}} + {{with .Title}}{{end}} + {{with .Description}}{{end}} + {{with .ImageURL}}{{end}} + {{with .ImageAlt}}{{end}} + {{with .TwitterSite}}{{end}} + {{with .FediverseCreator}}{{end}}
-

{{.Title}}

-

{{.Description}}

+

{{with .Title}}{{.}}{{else}}Open link{{end}}

+ {{with .Description}}

{{.}}

{{end}}

Open link

diff --git a/url.go b/url.go index 1fc8797..82e1064 100644 --- a/url.go +++ b/url.go @@ -33,7 +33,9 @@ func (s *Service) shortenURL(ctx context.Context, payload *Link) (string, error) } payload.ShortID = id - payload.CreatedAt = time.Now().UTC().Format(time.RFC3339) + now := time.Now().UTC().Format(time.RFC3339) + payload.CreatedAt = now + payload.UpdatedAt = now if err := s.config.Store.Save(ctx, id, payload); err != nil { return "", fmt.Errorf("store payload: %w", err)