feat: Open Graph link previews for image and video pages
Serves a static HTML page with full OG meta tags at /image/{id} for
social media crawlers (Discord, Telegram, Twitter, etc.). Real
browsers are redirected to the SPA via a script tag.
- og:title, og:description, og:url, og:image, og:image:secure_url
- og:type, og:locale, twitter:card=summary_large_image, theme-color
- og:video tags for video files with twitter:player for Discord embeds
- X-Forwarded-Proto support for reverse proxy HTTPS detection
- Canonical share URL changed to /image/{id} in the browse viewer
- Comprehensive test proving all required tags are served
Assisted-by: Crush:deepseek-v4-flash
Maxwell Jensen maxwelljensen@posteo.net
Thu, 14 May 2026 11:24:18 +0200
17 files changed,
305 insertions(+),
44 deletions(-)
jump to
M
cmd/weimar/image.go
→
cmd/weimar/image.go
@@ -4,9 +4,9 @@ import (
"fmt" "strconv" - "github.com/mjensen/weimar/internal/config" - "github.com/mjensen/weimar/internal/db" - "github.com/mjensen/weimar/internal/storage" + "codeberg.org/maxwelljensen/weimar/internal/config" + "codeberg.org/maxwelljensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/storage" "github.com/spf13/cobra" )
M
cmd/weimar/serve.go
→
cmd/weimar/serve.go
@@ -9,10 +9,10 @@ "os"
"os/signal" "syscall" - "github.com/mjensen/weimar/internal/auth" - "github.com/mjensen/weimar/internal/config" - "github.com/mjensen/weimar/internal/db" - "github.com/mjensen/weimar/internal/server" + "codeberg.org/maxwelljensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/config" + "codeberg.org/maxwelljensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/server" "github.com/spf13/cobra" "github.com/spf13/viper" )
M
cmd/weimar/users.go
→
cmd/weimar/users.go
@@ -4,9 +4,9 @@ import (
"fmt" "log/slog" - "github.com/mjensen/weimar/internal/auth" - "github.com/mjensen/weimar/internal/config" - "github.com/mjensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/config" + "codeberg.org/maxwelljensen/weimar/internal/db" "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt" )
M
internal/api/api.go
→
internal/api/api.go
@@ -5,10 +5,10 @@ "context"
"encoding/json" "net/http" - "github.com/mjensen/weimar/internal/auth" - "github.com/mjensen/weimar/internal/config" - "github.com/mjensen/weimar/internal/db" - "github.com/mjensen/weimar/internal/storage" + "codeberg.org/maxwelljensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/config" + "codeberg.org/maxwelljensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/storage" ) type ctxKey string
M
internal/api/api_test.go
→
internal/api/api_test.go
@@ -14,10 +14,10 @@ "net/http/httptest"
"strings" "testing" - "github.com/mjensen/weimar/internal/auth" - "github.com/mjensen/weimar/internal/config" - "github.com/mjensen/weimar/internal/db" - "github.com/mjensen/weimar/internal/storage" + "codeberg.org/maxwelljensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/config" + "codeberg.org/maxwelljensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/storage" ) func setupTest(t *testing.T) (*API, string) {
M
internal/api/auth.go
→
internal/api/auth.go
@@ -4,7 +4,7 @@ import (
"encoding/json" "net/http" - "github.com/mjensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/auth" ) type loginRequest struct {
M
internal/api/images.go
→
internal/api/images.go
@@ -5,7 +5,7 @@ "encoding/json"
"net/http" "strconv" - "github.com/mjensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/db" ) func (a *API) HandleListImages(w http.ResponseWriter, r *http.Request) {
M
internal/api/upload.go
→
internal/api/upload.go
@@ -6,8 +6,8 @@ "io"
"net/http" "strings" - "github.com/mjensen/weimar/internal/db" - "github.com/mjensen/weimar/internal/storage" + "codeberg.org/maxwelljensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/storage" ) func (a *API) HandleUpload(w http.ResponseWriter, r *http.Request) {
M
internal/auth/auth_test.go
→
internal/auth/auth_test.go
@@ -6,7 +6,7 @@ "database/sql"
"testing" "time" - "github.com/mjensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/db" ) func setupTestDB(t *testing.T) *db.DB {
M
internal/auth/register.go
→
internal/auth/register.go
@@ -6,7 +6,7 @@ "errors"
"fmt" "strings" - "github.com/mjensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/db" "golang.org/x/crypto/bcrypt" )
M
internal/auth/session.go
→
internal/auth/session.go
@@ -9,7 +9,7 @@ "errors"
"fmt" "time" - "github.com/mjensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/db" "golang.org/x/crypto/bcrypt" )
M
internal/server/middleware.go
→
internal/server/middleware.go
@@ -6,8 +6,8 @@ "log/slog"
"net/http" "strings" - "github.com/mjensen/weimar/internal/api" - "github.com/mjensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/api" + "codeberg.org/maxwelljensen/weimar/internal/auth" ) // requireAuth wraps a handler with session cookie authentication.
M
internal/server/server.go
→
internal/server/server.go
@@ -2,15 +2,17 @@ package server
import ( "context" + "fmt" "io/fs" "net/http" + "strconv" "strings" - "github.com/mjensen/weimar/internal/api" - "github.com/mjensen/weimar/internal/auth" - "github.com/mjensen/weimar/internal/config" - "github.com/mjensen/weimar/internal/db" - "github.com/mjensen/weimar/internal/storage" + "codeberg.org/maxwelljensen/weimar/internal/api" + "codeberg.org/maxwelljensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/config" + "codeberg.org/maxwelljensen/weimar/internal/db" + "codeberg.org/maxwelljensen/weimar/internal/storage" ) func New(cfg *config.Config, webFS fs.FS, database *db.DB) (*Server, error) {@@ -36,10 +38,15 @@ // Favicon and logo (public).
mux.HandleFunc("GET /favicon", h.HandleFavicon) mux.HandleFunc("GET /logo", h.HandleLogo) + spaH := spaHandler(webFS) + s := &Server{ - cfg: cfg, - mux: mux, - auth: sm, + cfg: cfg, + mux: mux, + auth: sm, + db: database, + webFS: webFS, + spaH: spaH, } // Auth (public).@@ -69,16 +76,22 @@ mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar)) mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar) - // SPA — serve index.html for all non-API routes (client-side routing). - mux.Handle("/", s.spaHandler(webFS)) + // Open Graph preview for social crawlers (image/video pages). + mux.HandleFunc("GET /image/{id}", s.handleOGPreview) + + // SPA — serve index.html for all other routes (client-side routing). + mux.Handle("/", spaH) return s, nil } type Server struct { - cfg *config.Config - mux *http.ServeMux - auth *auth.SessionManager + cfg *config.Config + mux *http.ServeMux + auth *auth.SessionManager + db *db.DB + webFS fs.FS + spaH http.HandlerFunc } func (s *Server) Start(ctx context.Context) error {@@ -120,10 +133,34 @@ }
return string(buf[i:]) } +// htmlEsc escapes HTML special characters for safe inclusion in HTML output. +func htmlEsc(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// formatFileSize returns a human-readable file size string. +func formatFileSize(bytes int64) string { + switch { + case bytes >= 1_000_000_000: + return fmt.Sprintf("%.1f GB", float64(bytes)/1_000_000_000) + case bytes >= 1_000_000: + return fmt.Sprintf("%.1f MB", float64(bytes)/1_000_000) + case bytes >= 1_000: + return fmt.Sprintf("%.1f KB", float64(bytes)/1_000) + default: + return fmt.Sprintf("%d B", bytes) + } +} + // spaHandler serves static files for SPA client-side routing. // For routes that don't match an existing file (e.g. /browse, /image/123), // it falls back to serving index.html so the SPA router can handle them. -func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc { +func spaHandler(webFS fs.FS) http.HandlerFunc { fsHandler := http.FileServer(http.FS(webFS)) return func(w http.ResponseWriter, r *http.Request) { // API routes should not be served by the SPA handler.@@ -144,6 +181,118 @@
// File exists — serve it directly. fsHandler.ServeHTTP(w, r) } +} + +// handleOGPreview serves a static HTML page with Open Graph meta tags at +// /image/{id}. All requests get the OG page so crawlers always see the +// preview tags. Real browsers are redirected to the SPA via a script tag +// (crawlers don't execute JavaScript). +func (s *Server) handleOGPreview(w http.ResponseWriter, r *http.Request) { + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + s.spaH.ServeHTTP(w, r) + return + } + + img, err := s.db.GetImageByID(r.Context(), id) + if err != nil { + s.spaH.ServeHTTP(w, r) + return + } + + // Determine scheme — check X-Forwarded-Proto for reverse proxy setups. + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + base := scheme + "://" + r.Host + thumbURL := base + "/api/images/" + idStr + "/thumbnail" + downloadURL := base + "/api/images/" + idStr + "/download" + pageURL := base + "/image/" + idStr + + // Look up uploader info. + uploaderUsername := "" + user, err := s.db.GetUserByID(r.Context(), img.UploadedBy) + if err == nil { + uploaderUsername = user.Username + } + + title := img.Filename + description := "Uploaded by " + uploaderUsername + if img.Width > 0 && img.Height > 0 { + description += fmt.Sprintf(" · %d×%d", img.Width, img.Height) + } + if img.FileSize > 0 { + description += fmt.Sprintf(" · %s", formatFileSize(img.FileSize)) + } + + isVideo := strings.HasPrefix(img.MimeType, "video/") + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + fmt.Fprintf(w, `<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<title>%s</title> +<meta name="description" content="%s"> +<meta property="og:locale" content="en"> +<meta property="og:title" content="%s"> +<meta property="og:description" content="%s"> +<meta property="og:url" content="%s"> +<meta property="og:image" content="%s"> +<meta property="og:image:secure_url" content="%s"> +<meta property="og:image:width" content="%d"> +<meta property="og:image:height" content="%d"> +<meta name="twitter:card" content="summary_large_image"> +<meta name="twitter:title" content="%s"> +<meta name="twitter:description" content="%s"> +<meta name="twitter:image" content="%s"> +<meta name="theme-color" content="#c62828"> +`, + htmlEsc(title), htmlEsc(description), + htmlEsc(title), htmlEsc(description), + htmlEsc(pageURL), + htmlEsc(thumbURL), htmlEsc(thumbURL), + img.Width, img.Height, + htmlEsc(title), htmlEsc(description), htmlEsc(thumbURL)) + + if isVideo { + fmt.Fprintf(w, `<meta property="og:type" content="video"> +<meta property="og:video" content="%s"> +<meta property="og:video:url" content="%s"> +<meta property="og:video:secure_url" content="%s"> +<meta property="og:video:type" content="%s"> +<meta property="og:video:width" content="%d"> +<meta property="og:video:height" content="%d"> +<meta name="twitter:card" content="player"> +<meta name="twitter:player" content="%s"> +<meta name="twitter:player:width" content="%d"> +<meta name="twitter:player:height" content="%d"> +<meta name="twitter:player:stream" content="%s"> +<meta name="twitter:player:stream:content_type" content="%s"> +`, + htmlEsc(downloadURL), + htmlEsc(downloadURL), htmlEsc(downloadURL), + htmlEsc(img.MimeType), + img.Width, img.Height, + htmlEsc(pageURL), + img.Width, img.Height, + htmlEsc(downloadURL), + htmlEsc(img.MimeType)) + } else { + fmt.Fprintf(w, `<meta property="og:type" content="website"> +`) + } + + fmt.Fprintf(w, `<meta property="og:site_name" content="weimar"> +<script>location.href="/browse?image=%s"</script> +</head> +<body> +<img src="%s" alt="%s" style="max-width:100%%"> +</body> +</html>`, idStr, htmlEsc(thumbURL), htmlEsc(title)) } func handleHealth(w http.ResponseWriter, r *http.Request) {
A
internal/server/server_test.go
@@ -0,0 +1,104 @@
+package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "codeberg.org/maxwelljensen/weimar/internal/auth" + "codeberg.org/maxwelljensen/weimar/internal/db" +) + +func TestHandleOGPreview_ServesAllRequiredTags(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := database.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + + // Create a user and an image directly. + auth.Register(context.Background(), database, "testuser", "password123", false) + img, err := database.CreateImage(context.Background(), &db.Image{ + Filename: "photo.jpg", + StoragePath: "ab/cd/ef/abcdef.jpg", + UploadedBy: 1, + FileSize: 204800, + Width: 1920, + Height: 1080, + MimeType: "image/jpeg", + }) + if err != nil { + t.Fatalf("create image: %v", err) + } + + // Construct a minimal Server with just the fields needed by handleOGPreview. + s := &Server{ + db: database, + // spaH is only called on error (missing image); we never hit it in this test. + spaH: func(w http.ResponseWriter, r *http.Request) {}, + } + + idStr := strconv.FormatInt(img.ID, 10) + req := httptest.NewRequest("GET", "/image/"+idStr, nil) + req.SetPathValue("id", idStr) + w := httptest.NewRecorder() + + s.handleOGPreview(w, req) + + resp := w.Result() + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + html := string(body) + + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200; body:\n%s", resp.StatusCode, html) + } + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", ct) + } + + // Verify all required OG tags are present by content. + required := []string{ + `og:title`, + `og:description`, + `og:url`, + `og:image`, + `og:image:secure_url`, + `og:type`, + `og:locale`, + `twitter:card`, + `twitter:image`, + } + for _, tag := range required { + if !strings.Contains(html, tag) { + t.Errorf("missing meta tag: %s", tag) + } + } + + // Verify specific values. + if !strings.Contains(html, "photo.jpg") { + t.Errorf("expected filename in OG title, got:\n%s", html) + } + if !strings.Contains(html, "testuser") { + t.Errorf("expected uploader in description, got:\n%s", html) + } + if !strings.Contains(html, "1920") { + t.Errorf("expected width in OG image:width, got:\n%s", html) + } + if strings.Contains(html, "&") || strings.Contains(html, "<") { + t.Errorf("HTML appears double-escaped:\n%s", html) + } + // Should NOT contain the SPA title placeholder. + if strings.Contains(html, "weimar") && !strings.Contains(html, "og:site_name") { + t.Errorf("response appears to be SPA fallback, not OG page:\n%s", html) + } +}
M
web/src/routes/browse/+page.svelte
→
web/src/routes/browse/+page.svelte
@@ -30,11 +30,13 @@ const params = new URLSearchParams(window.location.search);
filterTag = params.get('tag') || ''; filterUploader = params.get('uploader') || ''; loadImages().then(() => { + // Check if navigated from /image/{id} (e.g. after OG redirect). const imageParam = params.get('image'); if (imageParam) { const idx = images.findIndex((img) => String(img.id) === imageParam); if (idx !== -1) { viewerIndex = idx; + history.replaceState(null, '', '/image/' + imageParam); } else { goto('/image/' + imageParam); }@@ -136,7 +138,7 @@ }
function openViewer(index: number) { viewerIndex = index; - history.replaceState(null, '', '/browse?image=' + images[index].id); + history.replaceState(null, '', '/image/' + images[index].id); } function closeViewer() {@@ -145,6 +147,12 @@ const params = new URLSearchParams();
if (filterTag) params.set('tag', filterTag); if (filterUploader) params.set('uploader', filterUploader); history.replaceState(null, '', params.toString() ? '/browse?' + params.toString() : '/browse'); + // Restore main layout style. + const mainEl = document.querySelector('.main') as HTMLElement; + if (mainEl) { + mainEl.style.padding = '0'; + mainEl.style.maxWidth = 'none'; + } } </script>