package handlers import ( "bytes" "encoding/json" "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/jobs" "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 } 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 { 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 (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/"): 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) }