From ac9b8232f30e9e0c1aa91fdda74d9ed018fb99b8 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sun, 31 May 2026 17:57:56 +0300 Subject: [PATCH] feat(download): add dynamic OG metadata and fix thumbnail caching - Register a new route for box Open Graph images (`/d/{boxID}/og-image.jpg`). - Dynamically set the download page title, description, and OG image URL based on box state (e.g., file count, expiration, password protection). - Introduce `servePlaceholderThumbnail` to serve fallback thumbnails with `Cache-Control: no-store, must-revalidate`. This ensures the browser requests the real thumbnail once it is generated instead of caching the placeholder. --- backend/libs/handlers/app.go | 1 + backend/libs/handlers/download.go | 39 ++++++- backend/libs/handlers/ogimage.go | 176 ++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 backend/libs/handlers/ogimage.go diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index eb7341b..a7774a4 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -91,6 +91,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) + mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /health", a.Health) mux.HandleFunc("GET /healthz", a.Health) mux.HandleFunc("GET /api/v1/health", a.Health) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 0f86dfe..29cc195 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -76,9 +76,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { } } + expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST") + title := "Shared files on Warpbox" + description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel) + if locked && box.Obfuscate { + title = "Protected Warpbox link" + description = "This shared box is password protected." + } + a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{ - Title: "Download files", - Description: "Download files shared through Warpbox.", + Title: title, + Description: description, + ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)), Data: downloadPageData{ Box: boxView{ID: box.ID}, Files: files, @@ -87,11 +96,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { Obfuscated: box.Obfuscate, DownloadCount: box.DownloadCount, MaxDownloads: box.MaxDownloads, - ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"), + ExpiresLabel: expiresLabel, }, }) } +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) { box, file, ok := a.loadFileForRequest(w, r) if !ok { @@ -141,13 +157,18 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { return } if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) { - http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp")) + a.servePlaceholderThumbnail(w, r) return } object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file) if err != nil { - http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp")) + // The thumbnail isn't generated yet (background job pending). Serve the + // placeholder but mark it non-cacheable, otherwise the browser would + // keep showing the placeholder until a hard refresh once the real + // thumbnail lands. The real thumbnail below is content-stable, so it + // gets a long immutable cache. + a.servePlaceholderThumbnail(w, r) return } defer object.Body.Close() @@ -156,6 +177,14 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body)) } +// servePlaceholderThumbnail serves the fallback image with no-store so the +// browser re-requests on the next load and picks up the real thumbnail as soon +// as it has been generated. +func (a *App) servePlaceholderThumbnail(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store, must-revalidate") + http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp")) +} + func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) { box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { diff --git a/backend/libs/handlers/ogimage.go b/backend/libs/handlers/ogimage.go new file mode 100644 index 0000000..b608f39 --- /dev/null +++ b/backend/libs/handlers/ogimage.go @@ -0,0 +1,176 @@ +package handlers + +import ( + "bytes" + "image" + "image/color" + "image/draw" + _ "image/gif" + "image/jpeg" + _ "image/png" + "net/http" + "os" + "path/filepath" + "time" + + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +// Open Graph image dimensions recommended for large summary cards +// (Discord, Twitter/X, Slack, etc.). +const ( + ogImageWidth = 1200 + ogImageHeight = 630 + ogMaxTiles = 4 + ogTileGap = 8 +) + +var ogBackground = color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff} + +// BoxOGImage renders the social-preview image for a box: a collage of up to +// four file thumbnails, or a branded placeholder when none are available yet. +func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) { + box, err := a.uploadService.GetBox(r.PathValue("boxID")) + if err != nil { + http.NotFound(w, r) + return + } + if err := a.uploadService.CanDownload(box); err != nil { + a.serveOGImage(w, r, a.ogPlaceholder()) + return + } + + // Never leak thumbnails of a locked, obfuscated box. (Protected-but-not- + // obfuscated boxes already show their thumbnails on the download page, so + // they may appear here too.) + hideContents := a.uploadService.IsProtected(box) && box.Obfuscate + + thumbs := make([]image.Image, 0, ogMaxTiles) + if !hideContents { + for _, file := range box.Files { + if len(thumbs) >= ogMaxTiles { + break + } + if file.Thumbnail == "" && file.ThumbnailObjectKey == "" { + continue + } + object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file) + if err != nil { + continue + } + img, _, decodeErr := image.Decode(object.Body) + object.Body.Close() + if decodeErr == nil { + thumbs = append(thumbs, img) + } + } + } + + if len(thumbs) == 0 { + a.serveOGImage(w, r, a.ogPlaceholder()) + return + } + a.serveOGImage(w, r, renderCollage(thumbs)) +} + +func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil { + http.Error(w, "could not render preview image", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/jpeg") + // Social scrapers fetch this rarely and cache on their side; a modest cache + // keeps it fresh as thumbnails finish generating. + w.Header().Set("Cache-Control", "public, max-age=3600") + http.ServeContent(w, r, "og-image.jpg", time.Time{}, bytes.NewReader(buf.Bytes())) +} + +// ogPlaceholder builds the branded fallback image: the file placeholder icon +// centered on the brand background. +func (a *App) ogPlaceholder() image.Image { + canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight)) + draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src) + + file, err := os.Open(filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp")) + if err != nil { + return canvas + } + defer file.Close() + icon, _, err := image.Decode(file) + if err != nil { + return canvas + } + + // Scale the icon to ~40% of the canvas height and centre it. + target := ogImageHeight * 2 / 5 + b := icon.Bounds() + scale := float64(target) / float64(b.Dy()) + dw := int(float64(b.Dx()) * scale) + dh := target + x0 := (ogImageWidth - dw) / 2 + y0 := (ogImageHeight - dh) / 2 + xdraw.CatmullRom.Scale(canvas, image.Rect(x0, y0, x0+dw, y0+dh), icon, b, xdraw.Over, nil) + return canvas +} + +// renderCollage tiles up to four thumbnails into the OG canvas with a small gap. +func renderCollage(thumbs []image.Image) image.Image { + canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight)) + draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src) + + cols, rows := collageGrid(len(thumbs)) + cellW := (ogImageWidth - ogTileGap*(cols+1)) / cols + cellH := (ogImageHeight - ogTileGap*(rows+1)) / rows + + i := 0 + for ry := 0; ry < rows && i < len(thumbs); ry++ { + for cx := 0; cx < cols && i < len(thumbs); cx++ { + x0 := ogTileGap + cx*(cellW+ogTileGap) + y0 := ogTileGap + ry*(cellH+ogTileGap) + drawCover(canvas, image.Rect(x0, y0, x0+cellW, y0+cellH), thumbs[i]) + i++ + } + } + return canvas +} + +func collageGrid(n int) (cols, rows int) { + switch { + case n <= 1: + return 1, 1 + case n == 2: + return 2, 1 + case n == 3: + return 3, 1 + default: + return 2, 2 + } +} + +// drawCover scales src to completely fill dst, cropping the overflow (centred), +// preserving aspect ratio — the CSS object-fit: cover equivalent. +func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) { + b := src.Bounds() + iw, ih := b.Dx(), b.Dy() + if iw <= 0 || ih <= 0 { + return + } + cellAR := float64(cell.Dx()) / float64(cell.Dy()) + imgAR := float64(iw) / float64(ih) + + var sw, sh int + if imgAR > cellAR { + // Source is wider than the cell: crop the sides. + sh = ih + sw = int(float64(ih) * cellAR) + } else { + // Source is taller: crop top/bottom. + sw = iw + sh = int(float64(iw) / cellAR) + } + sx := b.Min.X + (iw-sw)/2 + sy := b.Min.Y + (ih-sh)/2 + xdraw.CatmullRom.Scale(dst, cell, src, image.Rect(sx, sy, sx+sw, sy+sh), xdraw.Over, nil) +}