2026-05-31 17:57:56 +03:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-06-08 03:56:42 +03:00
|
|
|
"encoding/json"
|
2026-06-03 14:55:19 +03:00
|
|
|
"fmt"
|
2026-05-31 17:57:56 +03:00
|
|
|
"image"
|
|
|
|
|
"image/color"
|
|
|
|
|
"image/draw"
|
|
|
|
|
_ "image/gif"
|
|
|
|
|
"image/jpeg"
|
|
|
|
|
_ "image/png"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
2026-06-03 14:55:19 +03:00
|
|
|
"strings"
|
2026-05-31 17:57:56 +03:00
|
|
|
"time"
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
"golang.org/x/image/font"
|
|
|
|
|
"golang.org/x/image/font/basicfont"
|
|
|
|
|
"golang.org/x/image/font/opentype"
|
|
|
|
|
"golang.org/x/image/math/fixed"
|
2026-05-31 17:57:56 +03:00
|
|
|
xdraw "golang.org/x/image/draw"
|
|
|
|
|
_ "golang.org/x/image/webp"
|
2026-06-03 14:55:19 +03:00
|
|
|
|
|
|
|
|
"warpbox.dev/backend/libs/helpers"
|
2026-06-08 03:56:42 +03:00
|
|
|
"warpbox.dev/backend/libs/jobs"
|
2026-06-03 14:55:19 +03:00
|
|
|
"warpbox.dev/backend/libs/services"
|
2026-05-31 17:57:56 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:56:42 +03:00
|
|
|
if jobs.NeedsArchiveListing(file) {
|
|
|
|
|
if listing, ok := a.archiveListingForOG(r, box, file); ok {
|
|
|
|
|
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
icon := a.ogFileIcon(file)
|
|
|
|
|
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:56:42 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:57:56 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:56:42 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:57:56 +03:00
|
|
|
// 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)
|
|
|
|
|
}
|