feat(backend): enhance social previews for single-file shares
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 42s
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 42s
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.
This commit is contained in:
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
@@ -11,10 +12,18 @@ import (
|
||||
"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
|
||||
@@ -74,6 +83,22 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -115,6 +140,158 @@ func (a *App) ogPlaceholder() image.Image {
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user