feat(ogimage): render custom OG images for archive files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m50s

Add support for generating and rendering rich Open Graph (OG) image cards for archive files. When an archive file is shared, the handler now fetches or generates its listing metadata and renders a custom card displaying file/folder counts, uncompressed size, and a visual representation of the archive's contents.
This commit is contained in:
2026-06-08 03:56:42 +03:00
parent a454e4239f
commit 45507cdcae
2 changed files with 239 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
@@ -23,6 +24,7 @@ import (
_ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
)
@@ -95,10 +97,65 @@ func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
return
}
if jobs.NeedsArchiveListing(file) {
if listing, ok := a.archiveListingForOG(r, box, file); ok {
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
return
}
}
icon := a.ogFileIcon(file)
a.serveOGImage(w, r, a.renderFileCard(file, icon))
}
type ogArchiveListing struct {
Name string `json:"name"`
Type string `json:"type"`
FileCount int `json:"fileCount"`
FolderCount int `json:"folderCount"`
UncompressedSize uint64 `json:"uncompressedSize"`
Root *ogArchiveNode `json:"root"`
}
type ogArchiveNode struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Dir bool `json:"dir"`
Icon string `json:"icon,omitempty"`
Items []*ogArchiveNode `json:"items,omitempty"`
}
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
}
}
if err != nil {
return ogArchiveListing{}, false
}
defer object.Body.Close()
var listing ogArchiveListing
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
return ogArchiveListing{}, false
}
if listing.Root == nil {
return ogArchiveListing{}, false
}
return listing, true
}
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 {
@@ -213,6 +270,174 @@ func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
return canvas
}
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) 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, 54, ogImageWidth-70, ogImageHeight-54)
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(36, true)
bodyFace := a.ogFont(22, false)
treeFace := a.ogFont(19, false)
labelFace := a.ogFont(17, true)
icon := a.ogFileIcon(file)
if icon != nil {
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
} else {
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
}
title := listing.Name
if strings.TrimSpace(title) == "" {
title = file.Name
}
titleLines := wrapOGText(title, titleFace, 820)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
}
y := 106
for _, line := range titleLines {
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
y += 42
}
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
treePanel := image.Rect(104, 214, 1096, 548)
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
rows := archiveOGRows(listing.Root, 13)
rowY := treePanel.Min.Y + 64
for _, row := range rows {
if row.Ellipsis {
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
break
}
x := treePanel.Min.X + 20 + row.Depth*28
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
name := row.Name
if row.Dir {
name += "/"
}
maxNameWidth := treePanel.Max.X - x - 170
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
if !row.Dir {
size := formatOGArchiveBytes(row.Size)
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
}
rowY += 23
}
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
return canvas
}
type archiveOGRow struct {
Name string
Icon string
Size uint64
Dir bool
Depth int
Ellipsis bool
}
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
rows := make([]archiveOGRow, 0, limit+1)
truncated := false
var walk func(items []*ogArchiveNode, depth int)
walk = func(items []*ogArchiveNode, depth int) {
for _, item := range items {
if len(rows) >= limit {
truncated = true
return
}
icon := item.Icon
if item.Dir {
icon = "folder"
}
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
if item.Dir {
walk(item.Items, depth+1)
}
}
}
if root != nil {
walk(root.Items, 0)
}
if truncated {
rows = append(rows, archiveOGRow{Ellipsis: true})
}
return rows
}
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
c := archiveOGIconColor(icon)
rect := image.Rect(x, y, x+20, y+20)
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
if icon == "folder" {
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
}
}
func archiveOGIconColor(icon string) color.RGBA {
switch icon {
case "folder":
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
case "img":
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
case "vid":
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
case "aud":
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
case "code":
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
case "arc":
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
default:
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
}
}
func archiveOGTextColor(row archiveOGRow) color.RGBA {
if row.Dir {
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
}
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
}
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
if strings.TrimSpace(listing.Type) != "" {
return listing.Type
}
if strings.TrimSpace(file.ContentType) != "" {
return file.ContentType
}
return "Archive"
}
func formatOGArchiveBytes(size uint64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
value := float64(size) / unit
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
value /= unit
}
return fmt.Sprintf("%.1f PiB", value)
}
func fileCardInfo(file services.File) string {
switch {
case strings.HasPrefix(file.ContentType, "audio/"):

View File

@@ -54,6 +54,20 @@
margin: 0.45rem 0 0;
}
.preview-header > .button {
flex: 0 0 auto;
padding-inline: 1rem;
overflow: visible;
}
.preview-header > .button svg {
flex: 0 0 auto;
}
[data-theme="retro"] .preview-header > .button-primary:active {
padding-right: calc(1rem - 1px);
}
.preview-window {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));