From 45507cdcaef49547767868630faf168eb8e40918 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 8 Jun 2026 03:56:42 +0300 Subject: [PATCH] feat(ogimage): render custom OG images for archive files 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. --- backend/libs/handlers/ogimage.go | 225 +++++++++++++++++++++++++++++ backend/static/css/30-download.css | 14 ++ 2 files changed, 239 insertions(+) diff --git a/backend/libs/handlers/ogimage.go b/backend/libs/handlers/ogimage.go index ac51ff8..bb73992 100644 --- a/backend/libs/handlers/ogimage.go +++ b/backend/libs/handlers/ogimage.go @@ -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/"): diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index 8faa19a..0ac4b91 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -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));