Files
warpbox-dev/backend/libs/jobs/thumbnails.go
Daniel Legt 3b278642dc
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 42s
feat(backend): enhance social previews for single-file shares
Implements dynamic Open Graph (OG) metadata and image generation for
single-file shared boxes to improve social media previews.

Changes include:
- Added a new route `/d/{boxID}/f/{fileID}/og-image.jpg` for file-specific OG images.
- Updated `DownloadPage` to dynamically set the page title, description, and OG image properties when a box contains only one file.
- Restricted raw media inline serving for social bots to images and videos.
- Added helper functions to format file share descriptions and determine appropriate social image URLs and types.
- Integrated basic font rendering to support dynamic OG image generation.
2026-06-03 14:55:19 +03:00

458 lines
14 KiB
Go

package jobs
import (
"bytes"
"context"
"html"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
_ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/services"
)
type ThumbnailJobResult struct {
Scanned int
Generated int
Failed int
}
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
go func() {
box, err := uploadService.GetBox(boxID)
if err != nil {
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
return
}
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
if err != nil {
logger.Warn("thumbnail one-shot job failed", "source", "thumbnail", "severity", "warn", "code", 4205, "box_id", boxID, "error", err.Error())
return
}
if result.Generated > 0 || result.Failed > 0 {
logger.Info("thumbnail one-shot job complete", "source", "thumbnail", "severity", "user_activity", "code", 2205, "box_id", boxID, "generated", result.Generated, "failed", result.Failed)
}
}()
}
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
return job{
name: "thumbnail",
enabled: cfg.ThumbnailEnabled,
interval: cfg.ThumbnailEvery,
run: func() {
result, err := generateMissingThumbnails(uploadService, logger)
if err != nil {
logger.Warn("thumbnail job failed", "source", "thumbnail", "severity", "warn", "code", 4203, "error", err.Error())
return
}
if result.Generated > 0 || result.Failed > 0 {
logger.Info("thumbnail job complete", "source", "thumbnail", "severity", "user_activity", "code", 2204, "generated", result.Generated, "failed", result.Failed)
}
},
}
}
func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
return generateMissingThumbnails(uploadService, logger)
}
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
boxes, err := uploadService.ListBoxes(0)
if err != nil {
return ThumbnailJobResult{}, err
}
var result ThumbnailJobResult
now := time.Now().UTC()
for _, box := range boxes {
if !box.ExpiresAt.After(now) {
continue
}
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
result.Scanned += boxResult.Scanned
result.Generated += boxResult.Generated
result.Failed += boxResult.Failed
if err != nil {
return result, err
}
}
return result, nil
}
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
var result ThumbnailJobResult
if !box.ExpiresAt.After(time.Now().UTC()) {
return result, nil
}
changed := false
for i := range box.Files {
file := &box.Files[i]
if file.Thumbnail != "" || !needsThumbnail(*file) {
continue
}
result.Scanned++
thumbnail, err := generateThumbnail(uploadService, box, *file)
if err != nil {
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
result.Failed++
continue
}
if thumbnail == "" {
result.Failed++
continue
}
file.Thumbnail = thumbnail
changed = true
result.Generated++
}
if changed {
if err := uploadService.SaveBox(box); err != nil {
return result, err
}
}
return result, nil
}
func needsThumbnail(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
}
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
thumbnailName := "@thumb@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil {
return "", err
}
defer object.Body.Close()
switch {
case strings.HasPrefix(file.ContentType, "image/"):
data, err := createImageThumbnail(object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return thumbnailName, err
case strings.HasPrefix(file.ContentType, "video/"):
data, err := createVideoThumbnail(object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return thumbnailName, err
case isTextThumbnailCandidate(file):
data, err := createTextThumbnail(file, object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return thumbnailName, err
default:
return "", nil
}
}
func isTextThumbnailCandidate(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
if strings.HasPrefix(contentType, "text/") {
return true
}
switch contentType {
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
return true
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
switch ext {
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
return true
default:
return false
}
}
func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source)
if err != nil {
return nil, err
}
thumb := resizeNearest(img, 360, 240)
var target bytes.Buffer
err = jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82})
if err != nil {
return nil, err
}
return target.Bytes(), nil
}
func createVideoThumbnail(source io.Reader) ([]byte, error) {
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
if err != nil {
return nil, err
}
defer os.Remove(sourceFile.Name())
if _, err := io.Copy(sourceFile, source); err != nil {
sourceFile.Close()
return nil, err
}
if err := sourceFile.Close(); err != nil {
return nil, err
}
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
if err != nil {
return nil, err
}
targetPath := targetFile.Name()
targetFile.Close()
defer os.Remove(targetPath)
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
return nil, err
}
return os.ReadFile(targetPath)
}
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
if err != nil {
return nil, err
}
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
mode := textThumbnailMode(file)
title := strings.ToUpper(mode)
var lines []string
if mode == "HTML" {
lines = renderedHTMLThumbnailLines(sourceText)
} else if mode == "MARKDOWN" {
lines = renderedMarkdownThumbnailLines(sourceText)
} else {
title = "CODE"
lines = codeThumbnailLines(sourceText)
}
return renderTextThumbnail(file.Name, title, lines), nil
}
func textThumbnailMode(file services.File) string {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if ext == "html" || ext == "htm" || contentType == "text/html" {
return "HTML"
}
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
return "MARKDOWN"
}
return "CODE"
}
func renderedHTMLThumbnailLines(source string) []string {
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
text = html.UnescapeString(text)
return documentThumbnailLines(text)
}
func renderedMarkdownThumbnailLines(source string) []string {
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
block = strings.Trim(block, "` \n\t")
lines := strings.Split(block, "\n")
if len(lines) > 1 {
lines = lines[1:]
}
return "\n" + strings.Join(lines, "\n") + "\n"
})
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
return documentThumbnailLines(text)
}
func documentThumbnailLines(source string) []string {
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
rawLines := strings.Split(source, "\n")
lines := make([]string, 0, 9)
for _, raw := range rawLines {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
for _, line := range wrapTextThumbnailLine(raw, 43) {
lines = append(lines, line)
if len(lines) >= 9 {
return lines
}
}
}
if len(lines) == 0 {
return []string{"Rendered preview is empty."}
}
return lines
}
func codeThumbnailLines(source string) []string {
rawLines := strings.Split(source, "\n")
lines := make([]string, 0, 10)
for _, raw := range rawLines {
raw = strings.ReplaceAll(raw, "\t", " ")
raw = strings.TrimRight(raw, " ")
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
continue
}
if len(raw) > 48 {
raw = raw[:45] + "..."
}
lines = append(lines, raw)
if len(lines) >= 10 {
break
}
}
if len(lines) == 0 {
return []string{"(empty file)"}
}
return lines
}
func renderTextThumbnail(name, mode string, lines []string) []byte {
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
face := basicfont.Face7x13
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
codePane := image.Rect(22, 72, 338, 210)
if mode == "CODE" {
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
} else {
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
}
y := 91
for _, line := range lines {
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
y += 14
if y > 202 {
break
}
}
var target bytes.Buffer
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
return target.Bytes()
}
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
}
func drawThumbText(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 wrapTextThumbnailLine(text string, maxChars int) []string {
if len(text) <= maxChars {
return []string{text}
}
words := strings.Fields(text)
if len(words) == 0 {
return []string{text[:maxChars-3] + "..."}
}
lines := []string{}
current := ""
for _, word := range words {
if current == "" {
current = word
continue
}
if len(current)+1+len(word) <= maxChars {
current += " " + word
continue
}
lines = append(lines, trimThumbnailText(current, maxChars))
current = word
}
if current != "" {
lines = append(lines, trimThumbnailText(current, maxChars))
}
return lines
}
func trimThumbnailText(text string, maxChars int) string {
if len(text) <= maxChars {
return text
}
if maxChars <= 3 {
return text[:maxChars]
}
return strings.TrimSpace(text[:maxChars-3]) + "..."
}
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return image.NewRGBA(image.Rect(0, 0, 1, 1))
}
scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
if scale > 1 {
scale = 1
}
targetWidth := max(1, int(float64(width)*scale))
targetHeight := max(1, int(float64(height)*scale))
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
srcX := bounds.Min.X + int(float64(x)/scale)
srcY := bounds.Min.Y + int(float64(y)/scale)
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}