feat(backend): enhance social previews for single-file shares
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 42s

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.
This commit is contained in:
2026-06-03 14:55:19 +03:00
parent 3a0dd04e61
commit 3b278642dc
9 changed files with 581 additions and 32 deletions

View File

@@ -132,6 +132,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile) mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /robots.txt", a.RobotsTxt) mux.HandleFunc("GET /robots.txt", a.RobotsTxt)

View File

@@ -104,14 +104,17 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
} }
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 { if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
if box.Files[0].Processing { file := box.Files[0]
if file.Processing {
http.Error(w, "file is still processing", http.StatusAccepted) http.Error(w, "file is still processing", http.StatusAccepted)
return return
} }
a.serveFileContent(w, r, box, box.Files[0], false) if shouldServeRawSocialMedia(file) {
a.logger.Info("single-file box served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", box.Files[0].ID)...) a.serveFileContent(w, r, box, file, false)
a.logger.Info("single-file media served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", file.ID)...)
return return
} }
}
visitorID := a.reactionVisitorID(w, r) visitorID := a.reactionVisitorID(w, r)
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID) reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
if err != nil { if err != nil {
@@ -132,13 +135,25 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST") expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox" title := "Shared files on Warpbox"
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel) description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
imageType := "image/jpeg"
if !locked && len(box.Files) == 1 && !box.Files[0].Processing {
file := box.Files[0]
view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size)
title = file.Name
description = fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
ogImage = socialImageURL(r, box, file, view)
imageAlt = fmt.Sprintf("Download card for %s", file.Name)
imageType = socialImageType(file)
}
if locked && box.Obfuscate { if locked && box.Obfuscate {
title = "Protected Warpbox link" title = "Protected Warpbox link"
description = "This shared box is password protected." description = "This shared box is password protected."
} }
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID)) pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
// All user uploads are private/temporary — noindex by default. // All user uploads are private/temporary — noindex by default.
robots := web.RobotsNone robots := web.RobotsNone
@@ -149,7 +164,8 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
CanonicalURL: pageURL, CanonicalURL: pageURL,
Robots: robots, Robots: robots,
ImageURL: ogImage, ImageURL: ogImage,
ImageAlt: fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files))), ImageAlt: imageAlt,
ImageType: imageType,
Data: downloadPageData{ Data: downloadPageData{
Box: boxView{ID: box.ID}, Box: boxView{ID: box.ID},
Files: files, Files: files,
@@ -172,6 +188,43 @@ func plural(n int) string {
return "s" return "s"
} }
func shouldServeRawSocialMedia(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video"
}
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
if strings.TrimSpace(contentType) == "" {
contentType = "file"
}
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
}
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
if file.PreviewKind == "image" {
return absoluteURL(r, view.DownloadURL+"?inline=1")
}
if file.PreviewKind == "video" && view.HasThumbnail {
return absoluteURL(r, view.ThumbnailURL)
}
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
}
func socialImageType(file services.File) string {
if file.PreviewKind == "image" {
return file.ContentType
}
return "image/jpeg"
}
func socialOGType(file services.File) string {
switch file.PreviewKind {
case "video":
return "video.other"
default:
return "website"
}
}
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) { func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r) box, file, ok := a.loadFileForRequest(w, r)
if !ok { if !ok {
@@ -184,21 +237,30 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
http.Error(w, "file is still processing", http.StatusAccepted) http.Error(w, "file is still processing", http.StatusAccepted)
return return
} }
if shouldServeRawSocialMedia(file) {
a.serveFileContent(w, r, box, file, false) a.serveFileContent(w, r, box, file, false)
a.logger.Info("file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...) a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
return return
} }
}
view := a.fileView(box, file) view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size) fileSize := helpers.FormatBytes(file.Size)
title := file.Name title := file.Name
description := fmt.Sprintf("%s · %s file shared via Warp Box", fileSize, file.ContentType) description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
imageURL := absoluteURL(r, view.ThumbnailURL) imageURL := socialImageURL(r, box, file, view)
imageAlt := fmt.Sprintf("Preview of %s", file.Name) imageAlt := fmt.Sprintf("Download card for %s", file.Name)
ogType := socialOGType(file)
mediaURL := ""
if file.PreviewKind == "video" {
mediaURL = absoluteURL(r, view.DownloadURL+"?inline=1")
}
if locked && box.Obfuscate { if locked && box.Obfuscate {
title = "Protected Warpbox file" title = "Protected Warpbox file"
description = "This shared file is password protected." description = "This shared file is password protected."
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp") imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
imageAlt = "Password protected file on Warp Box" imageAlt = "Password protected file on Warp Box"
ogType = "website"
mediaURL = ""
} }
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID)) pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID))
@@ -208,8 +270,12 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
Description: description, Description: description,
CanonicalURL: pageURL, CanonicalURL: pageURL,
Robots: web.RobotsNone, Robots: web.RobotsNone,
OGType: ogType,
ImageURL: imageURL, ImageURL: imageURL,
ImageAlt: imageAlt, ImageAlt: imageAlt,
ImageType: socialImageType(file),
MediaURL: mediaURL,
MediaType: file.ContentType,
Data: previewPageData{ Data: previewPageData{
Box: boxView{ID: box.ID}, Box: boxView{ID: box.ID},
File: view, File: view,

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"bytes" "bytes"
"fmt"
"image" "image"
"image/color" "image/color"
"image/draw" "image/draw"
@@ -11,10 +12,18 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "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" xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
) )
// Open Graph image dimensions recommended for large summary cards // Open Graph image dimensions recommended for large summary cards
@@ -74,6 +83,22 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
a.serveOGImage(w, r, renderCollage(thumbs)) 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
}
icon := a.ogFileIcon(file)
a.serveOGImage(w, r, a.renderFileCard(file, icon))
}
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) { func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil { if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
@@ -115,6 +140,158 @@ func (a *App) ogPlaceholder() image.Image {
return canvas 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 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. // renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
func renderCollage(thumbs []image.Image) image.Image { func renderCollage(thumbs []image.Image) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight)) canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))

View File

@@ -106,7 +106,7 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
} }
} }
func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) { func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
payload := uploadThroughApp(t, app) payload := uploadThroughApp(t, app)
@@ -120,15 +120,16 @@ func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
if response.Code != http.StatusOK { if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
if strings.Contains(response.Body.String(), "Shared files on Warpbox") { body := response.Body.String()
t.Fatalf("social preview bot received HTML download page") if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
} }
if response.Body.String() != "hello" { if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
t.Fatalf("social preview body = %q", response.Body.String()) t.Fatalf("social preview body missing preview/download description: %s", body)
} }
} }
func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) { func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
payload := uploadThroughApp(t, app) payload := uploadThroughApp(t, app)
@@ -143,11 +144,46 @@ func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
if response.Code != http.StatusOK { if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
if strings.Contains(response.Body.String(), "preview-title") { body := response.Body.String()
t.Fatalf("social preview bot received HTML preview page") if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
} }
if response.Body.String() != "hello" { if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
t.Fatalf("social preview body = %q", response.Body.String()) t.Fatalf("social preview body missing twitter card metadata: %s", body)
}
}
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
request.Header.Set("Accept", "application/json")
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, request)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
previewRequest.SetPathValue("boxID", payload.BoxID)
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFile(response, previewRequest)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if strings.Contains(response.Body.String(), "preview-title") {
t.Fatalf("image social preview bot received HTML preview page")
}
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
t.Fatalf("image social preview body = %q", response.Body.String())
} }
} }

View File

@@ -3,18 +3,25 @@ package jobs
import ( import (
"bytes" "bytes"
"context" "context"
"html"
"image" "image"
"image/color"
"image/draw"
_ "image/gif" _ "image/gif"
"image/jpeg" "image/jpeg"
_ "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"regexp"
"strings" "strings"
"time" "time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/config" "warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
@@ -131,7 +138,7 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
} }
func needsThumbnail(file services.File) bool { func needsThumbnail(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video" return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
} }
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
@@ -157,11 +164,39 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
} }
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg") _, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return thumbnailName, err 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: default:
return "", nil 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) { func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source) img, _, err := image.Decode(source)
if err != nil { if err != nil {
@@ -203,6 +238,197 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
return os.ReadFile(targetPath) 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 { func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
bounds := src.Bounds() bounds := src.Bounds()
width := bounds.Dx() width := bounds.Dx()

View File

@@ -4,12 +4,14 @@ import (
"bytes" "bytes"
"image" "image"
"image/color" "image/color"
"image/jpeg"
"image/png" "image/png"
"io" "io"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http/httptest" "net/http/httptest"
"net/textproto" "net/textproto"
"strings"
"testing" "testing"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
@@ -46,6 +48,30 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
} }
} }
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
data, err := createTextThumbnail(services.File{
Name: "notes.md",
ContentType: "text/markdown",
}, strings.NewReader("# Meeting notes\n\n```go\nfunc main() {}\n```\n\nA rendered Markdown preview."))
if err != nil {
t.Fatalf("createTextThumbnail returned error: %v", err)
}
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("jpeg.Decode returned error: %v", err)
}
if img.Bounds().Dx() != 360 || img.Bounds().Dy() != 240 {
t.Fatalf("thumbnail size = %dx%d, want 360x240", img.Bounds().Dx(), img.Bounds().Dy())
}
}
func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
if !needsThumbnail(services.File{Name: "main.go", ContentType: "text/plain"}) {
t.Fatalf("Go source file should get a text thumbnail")
}
}
func newThumbnailTestUploadService(t *testing.T) *services.UploadService { func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
t.Helper() t.Helper()
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil))) service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))

View File

@@ -23,10 +23,14 @@ type PageData struct {
BaseURL string BaseURL string
CanonicalURL string CanonicalURL string
Robots string Robots string
OGType string
Title string Title string
Description string Description string
ImageURL string ImageURL string
ImageAlt string ImageAlt string
ImageType string
MediaURL string
MediaType string
CurrentYear int CurrentYear int
CurrentUser any CurrentUser any
CSRFToken string CSRFToken string

View File

@@ -110,19 +110,18 @@
} }
function chooseDefaultMode(type, tabs) { function chooseDefaultMode(type, tabs) {
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
if (type.isAudio && hasMode(tabs, "browser-audio")) {
return "browser-audio";
}
return "default";
}
if (type.isImage) { if (type.isImage) {
return "image"; return "image";
} }
if (type.isVideo) { if (type.isVideo) {
return "video"; return "video";
} }
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
if (type.isAudio && hasMode(tabs, "browser-audio")) {
return "browser-audio";
}
return "default";
}
if (type.isAudio) { if (type.isAudio) {
return "browser-audio"; return "browser-audio";
} }

View File

@@ -11,16 +11,30 @@
<meta name="generator" content="Warp Box {{.AppVersion}}"> <meta name="generator" content="Warp Box {{.AppVersion}}">
<meta property="og:site_name" content="{{.AppName}}"> <meta property="og:site_name" content="{{.AppName}}">
<meta property="og:type" content="website"> <meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}"> <meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
<meta property="og:description" content="{{.Description}}"> <meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}"> <meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
{{if .ImageURL}} {{if .ImageURL}}
<meta property="og:image" content="{{.ImageURL}}"> <meta property="og:image" content="{{.ImageURL}}">
<meta property="og:image:secure_url" content="{{.ImageURL}}">
{{if .ImageType}}<meta property="og:image:type" content="{{.ImageType}}">{{end}}
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}} {{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
{{end}} {{end}}
{{if .MediaURL}}
{{if eq .OGType "video.other"}}
<meta property="og:video" content="{{.MediaURL}}">
<meta property="og:video:secure_url" content="{{.MediaURL}}">
{{if .MediaType}}<meta property="og:video:type" content="{{.MediaType}}">{{end}}
{{end}}
{{if eq .OGType "music.song"}}
<meta property="og:audio" content="{{.MediaURL}}">
<meta property="og:audio:secure_url" content="{{.MediaURL}}">
{{if .MediaType}}<meta property="og:audio:type" content="{{.MediaType}}">{{end}}
{{end}}
{{end}}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}"> <meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">