package handlers import ( "bytes" "image" "image/color" "image/draw" _ "image/gif" "image/jpeg" _ "image/png" "net/http" "os" "path/filepath" "time" xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" ) // 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)) } 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 } // 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) }