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.
This commit is contained in:
@@ -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}", a.DownloadFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
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 /health", a.Health)
|
||||||
mux.HandleFunc("GET /healthz", a.Health)
|
mux.HandleFunc("GET /healthz", a.Health)
|
||||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||||
|
|||||||
@@ -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{
|
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
||||||
Title: "Download files",
|
Title: title,
|
||||||
Description: "Download files shared through Warpbox.",
|
Description: description,
|
||||||
|
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
Files: files,
|
Files: files,
|
||||||
@@ -87,11 +96,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
Obfuscated: box.Obfuscate,
|
Obfuscated: box.Obfuscate,
|
||||||
DownloadCount: box.DownloadCount,
|
DownloadCount: box.DownloadCount,
|
||||||
MaxDownloads: box.MaxDownloads,
|
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) {
|
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -141,13 +157,18 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
defer object.Body.Close()
|
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))
|
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) {
|
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
176
backend/libs/handlers/ogimage.go
Normal file
176
backend/libs/handlers/ogimage.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user