Files
warpbox-dev/backend/libs/handlers/ogimage.go
Daniel Legt 3b278642dc
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 42s
feat(backend): enhance social previews for single-file shares
Implements dynamic Open Graph (OG) metadata and image generation for
single-file shared boxes to improve social media previews.

Changes include:
- Added a new route `/d/{boxID}/f/{fileID}/og-image.jpg` for file-specific OG images.
- Updated `DownloadPage` to dynamically set the page title, description, and OG image properties when a box contains only one file.
- Restricted raw media inline serving for social bots to images and videos.
- Added helper functions to format file share descriptions and determine appropriate social image URLs and types.
- Integrated basic font rendering to support dynamic OG image generation.
2026-06-03 14:55:19 +03:00

354 lines
11 KiB
Go

package handlers
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
)
// 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))
}
// FileOGImage renders a branded card for files that should not be served as raw
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
a.serveOGImage(w, r, a.ogPlaceholder())
return
}
icon := a.ogFileIcon(file)
a.serveOGImage(w, r, a.renderFileCard(file, icon))
}
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
}
func (a *App) ogFileIcon(file services.File) image.Image {
if a.fileIcons == nil {
return nil
}
icon := a.fileIcons.lookup(file.Name, file.ContentType)
if icon.Retro == "" {
return nil
}
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil
}
return img
}
func (a *App) renderFileCard(file services.File, icon 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)
panel := image.Rect(70, 72, ogImageWidth-70, ogImageHeight-72)
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
titleFace := a.ogFont(44, true)
bodyFace := a.ogFont(28, false)
metaFace := a.ogFont(24, false)
buttonFace := a.ogFont(26, true)
if icon != nil {
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
} else {
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
}
titleLines := wrapOGText(file.Name, titleFace, 850)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
}
y := 156
for _, line := range titleLines {
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
y += 52
}
size := helpers.FormatBytes(file.Size)
typeLabel := strings.TrimSpace(file.ContentType)
if typeLabel == "" {
typeLabel = "application/octet-stream"
}
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
info := fileCardInfo(file)
for i, line := range wrapOGText(info, metaFace, 900) {
if i >= 2 {
break
}
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
}
button := image.Rect(110, 474, 430, 548)
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
return canvas
}
func fileCardInfo(file services.File) string {
switch {
case strings.HasPrefix(file.ContentType, "audio/"):
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
case file.ContentType == "text/markdown":
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
case strings.Contains(file.ContentType, "html"):
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
case strings.Contains(file.ContentType, "pdf"):
return "PDF file shared through Warpbox. Open the link to download the original file."
case strings.HasPrefix(file.ContentType, "text/"):
return "Text file shared through Warpbox. Open the link to preview the content or download."
default:
return "File shared through Warpbox. Open the link to preview available details or download the original."
}
}
func (a *App) ogFont(size float64, bold bool) font.Face {
name := "PixeloidSans.ttf"
if bold {
name = "PixeloidSans-Bold.ttf"
}
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
if err != nil {
return basicfont.Face7x13
}
parsed, err := opentype.Parse(data)
if err != nil {
return basicfont.Face7x13
}
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return basicfont.Face7x13
}
return face
}
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
d := font.Drawer{
Dst: dst,
Src: image.NewUniform(c),
Face: face,
Dot: fixed.P(x, y),
}
d.DrawString(text)
}
func wrapOGText(text string, face font.Face, maxWidth int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{text}
}
lines := []string{}
current := words[0]
for _, word := range words[1:] {
next := current + " " + word
if ogTextWidth(face, next) <= maxWidth {
current = next
continue
}
lines = append(lines, current)
current = word
}
lines = append(lines, current)
return lines
}
func trimOGText(text string, face font.Face, maxWidth int) string {
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
text = text[:len(text)-1]
}
return strings.TrimSpace(text) + "..."
}
func ogTextWidth(face font.Face, text string) int {
bounds, _ := font.BoundString(face, text)
return (bounds.Max.X - bounds.Min.X).Ceil()
}
// 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)
}