feat: add thumbnail metadata and download endpoint

- Extend `BoxFile` with thumbnail path/status fields and internal URL
- Populate `ThumbnailURL` when a thumbnail path is present during decoration
- Add `/box/:id/thumbnails/:file_id` route and handler to serve JPEG thumbnails
- Introduce thumbnail status constants to standardize processing state reportingfeat: add thumbnail metadata and download endpoint

- Extend `BoxFile` with thumbnail path/status fields and internal URL
- Populate `ThumbnailURL` when a thumbnail path is present during decoration
- Add `/box/:id/thumbnails/:file_id` route and handler to serve JPEG thumbnails
- Introduce thumbnail status constants to standardize processing state reporting
This commit is contained in:
2026-04-28 18:44:16 +03:00
parent c1489d1fbb
commit f1600faa8d
12 changed files with 400 additions and 17 deletions

View File

@@ -329,6 +329,9 @@ func DecorateFile(boxID string, file models.BoxFile) models.BoxFile {
} }
file.IconPath = IconForMimeType(file.MimeType, file.Name) file.IconPath = IconForMimeType(file.MimeType, file.Name)
if file.ThumbnailPath != nil {
file.ThumbnailURL = *file.ThumbnailPath
}
file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name) file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name)
file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload" file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload"
file.IsComplete = file.Status == models.FileStatusReady file.IsComplete = file.Status == models.FileStatusReady

View File

@@ -0,0 +1,44 @@
package boxstore
import (
"testing"
"time"
"warpbox/lib/models"
)
func TestStartRetentionWaitsForEveryFileToFinish(t *testing.T) {
manifest := models.BoxManifest{
RetentionSecs: 10,
Files: []models.BoxFile{
{ID: "one", Status: models.FileStatusReady},
{ID: "two", Status: models.FileStatusWork},
},
}
startRetentionIfTerminalUnlocked(&manifest)
if !manifest.ExpiresAt.IsZero() {
t.Fatalf("expected retention to stay unset while a file is still uploading, got %s", manifest.ExpiresAt)
}
}
func TestStartRetentionBeginsWhenEveryFileIsTerminal(t *testing.T) {
manifest := models.BoxManifest{
RetentionSecs: 10,
Files: []models.BoxFile{
{ID: "one", Status: models.FileStatusReady},
{ID: "two", Status: models.FileStatusFailed},
},
}
before := time.Now().UTC()
startRetentionIfTerminalUnlocked(&manifest)
if manifest.ExpiresAt.IsZero() {
t.Fatal("expected retention to start once every file is complete or failed")
}
if manifest.ExpiresAt.Before(before.Add(9 * time.Second)) {
t.Fatalf("expected retention to start from completion time, got %s", manifest.ExpiresAt)
}
}

246
lib/boxstore/thumbnails.go Normal file
View File

@@ -0,0 +1,246 @@
package boxstore
import (
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/gif"
_ "image/png"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
const (
thumbnailDir = ".thumbnails"
thumbnailMaxSize = 160
)
type thumbnailTask struct {
BoxID string
FileID string
Name string
}
func StartThumbnailWorker(batchSize int, interval time.Duration) {
if batchSize < 1 {
batchSize = 10
}
if interval <= 0 {
interval = 30 * time.Second
}
go func() {
for {
ProcessThumbnailBatch(batchSize)
time.Sleep(interval)
}
}()
}
func ProcessThumbnailBatch(batchSize int) int {
tasks := collectThumbnailTasks(batchSize)
for _, task := range tasks {
if err := generateThumbnail(task); err != nil {
markThumbnailFailed(task.BoxID, task.FileID)
continue
}
}
return len(tasks)
}
func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
if !helpers.ValidLowerHexID(fileID, 16) {
return "", false
}
return helpers.SafeChildPath(filepath.Join(BoxPath(boxID), thumbnailDir), fileID+".jpg")
}
func collectThumbnailTasks(batchSize int) []thumbnailTask {
entries, err := os.ReadDir(UploadRoot)
if err != nil {
return nil
}
tasks := make([]thumbnailTask, 0, batchSize)
for _, entry := range entries {
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
continue
}
tasks = append(tasks, collectBoxThumbnailTasks(entry.Name(), batchSize-len(tasks))...)
if len(tasks) >= batchSize {
return tasks
}
}
return tasks
}
func collectBoxThumbnailTasks(boxID string, remaining int) []thumbnailTask {
if remaining <= 0 {
return nil
}
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil || IsExpired(manifest) {
return nil
}
tasks := make([]thumbnailTask, 0, remaining)
changed := false
for index, file := range manifest.Files {
if len(tasks) >= remaining {
break
}
if file.Status != models.FileStatusReady || file.ThumbnailPath != nil || file.ThumbnailStatus != "" {
continue
}
if !canGenerateThumbnail(file) {
manifest.Files[index].ThumbnailStatus = models.ThumbnailStatusUnsupported
changed = true
continue
}
tasks = append(tasks, thumbnailTask{
BoxID: boxID,
FileID: file.ID,
Name: file.Name,
})
}
if changed {
writeManifestUnlocked(boxID, manifest)
}
return tasks
}
func canGenerateThumbnail(file models.BoxFile) bool {
if strings.HasPrefix(file.MimeType, "image/") {
return true
}
extension := strings.ToLower(filepath.Ext(file.Name))
return extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".gif"
}
func generateThumbnail(task thumbnailTask) error {
source, err := os.Open(filepath.Join(BoxPath(task.BoxID), task.Name))
if err != nil {
return err
}
defer source.Close()
src, _, err := image.Decode(source)
if err != nil {
return err
}
thumb := resizeImage(src, thumbnailMaxSize)
if err := os.MkdirAll(filepath.Join(BoxPath(task.BoxID), thumbnailDir), 0755); err != nil {
return err
}
path, ok := ThumbnailFilePath(task.BoxID, task.FileID)
if !ok {
return os.ErrInvalid
}
target, err := os.Create(path)
if err != nil {
return err
}
defer target.Close()
if err := jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82}); err != nil {
return err
}
return markThumbnailReady(task.BoxID, task.FileID)
}
func resizeImage(src image.Image, maxSize int) image.Image {
bounds := src.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return src
}
targetWidth := width
targetHeight := height
if width > maxSize || height > maxSize {
if width >= height {
targetWidth = maxSize
targetHeight = height * maxSize / width
} else {
targetHeight = maxSize
targetWidth = width * maxSize / height
}
}
if targetWidth < 1 {
targetWidth = 1
}
if targetHeight < 1 {
targetHeight = 1
}
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
srcX := bounds.Min.X + x*width/targetWidth
srcY := bounds.Min.Y + y*height/targetHeight
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
func markThumbnailReady(boxID string, fileID string) error {
path := "/box/" + boxID + "/thumbnails/" + url.PathEscape(fileID)
return updateThumbnailState(boxID, fileID, &path, models.ThumbnailStatusReady)
}
func markThumbnailFailed(boxID string, fileID string) {
updateThumbnailState(boxID, fileID, nil, models.ThumbnailStatusFailed)
}
func updateThumbnailState(boxID string, fileID string, thumbnailPath *string, status string) error {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return err
}
for index, file := range manifest.Files {
if file.ID != fileID {
continue
}
manifest.Files[index].ThumbnailPath = thumbnailPath
manifest.Files[index].ThumbnailStatus = status
return writeManifestUnlocked(boxID, manifest)
}
return os.ErrNotExist
}

View File

@@ -9,6 +9,13 @@ const (
FileStatusWork = "uploading" FileStatusWork = "uploading"
) )
const (
ThumbnailStatusFailed = "failed"
ThumbnailStatusProcessing = "processing"
ThumbnailStatusReady = "ready"
ThumbnailStatusUnsupported = "unsupported"
)
type RetentionOption struct { type RetentionOption struct {
Key string `json:"key"` Key string `json:"key"`
Label string `json:"label"` Label string `json:"label"`
@@ -25,6 +32,9 @@ type BoxFile struct {
StatusLabel string `json:"status_label"` StatusLabel string `json:"status_label"`
Title string `json:"title"` Title string `json:"title"`
IconPath string `json:"icon_path"` IconPath string `json:"icon_path"`
ThumbnailPath *string `json:"thumbnail_path"`
ThumbnailStatus string `json:"thumbnail_status,omitempty"`
ThumbnailURL string `json:"-"`
DownloadPath string `json:"download_path"` DownloadPath string `json:"download_path"`
UploadPath string `json:"upload_path"` UploadPath string `json:"upload_path"`
IsComplete bool `json:"is_complete"` IsComplete bool `json:"is_complete"`

View File

@@ -10,6 +10,7 @@ type Handlers struct {
BoxStatus gin.HandlerFunc BoxStatus gin.HandlerFunc
DownloadBox gin.HandlerFunc DownloadBox gin.HandlerFunc
DownloadFile gin.HandlerFunc DownloadFile gin.HandlerFunc
DownloadThumbnail gin.HandlerFunc
CreateBox gin.HandlerFunc CreateBox gin.HandlerFunc
ManifestFileUpload gin.HandlerFunc ManifestFileUpload gin.HandlerFunc
FileStatusUpdate gin.HandlerFunc FileStatusUpdate gin.HandlerFunc
@@ -25,6 +26,7 @@ func Register(router *gin.Engine, handlers Handlers) {
router.GET("/box/:id/status", handlers.BoxStatus) router.GET("/box/:id/status", handlers.BoxStatus)
router.GET("/box/:id/download", handlers.DownloadBox) router.GET("/box/:id/download", handlers.DownloadBox)
router.GET("/box/:id/files/:filename", handlers.DownloadFile) router.GET("/box/:id/files/:filename", handlers.DownloadFile)
router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail)
router.POST("/box", handlers.CreateBox) router.POST("/box", handlers.CreateBox)
router.POST("/box/:id/login", handlers.BoxLoginPost) router.POST("/box/:id/login", handlers.BoxLoginPost)

View File

@@ -208,6 +208,33 @@ func handleDownloadFile(ctx *gin.Context) {
ctx.FileAttachment(path, filename) ctx.FileAttachment(path, filename)
} }
func handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized {
return
}
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "Thumbnail not found")
return
}
ctx.Header("Content-Type", "image/jpeg")
ctx.File(path)
}
func handleCreateBox(ctx *gin.Context) { func handleCreateBox(ctx *gin.Context) {
boxID, err := boxstore.NewBoxID() boxID, err := boxstore.NewBoxID()
if err != nil { if err != nil {

View File

@@ -1,9 +1,13 @@
package server package server
import ( import (
"time"
"github.com/gin-contrib/gzip" "github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/routing" "warpbox/lib/routing"
) )
@@ -19,6 +23,7 @@ func Run(addr string) error {
BoxStatus: handleBoxStatus, BoxStatus: handleBoxStatus,
DownloadBox: handleDownloadBox, DownloadBox: handleDownloadBox,
DownloadFile: handleDownloadFile, DownloadFile: handleDownloadFile,
DownloadThumbnail: handleDownloadThumbnail,
CreateBox: handleCreateBox, CreateBox: handleCreateBox,
ManifestFileUpload: handleManifestFileUpload, ManifestFileUpload: handleManifestFileUpload,
FileStatusUpdate: handleFileStatusUpdate, FileStatusUpdate: handleFileStatusUpdate,
@@ -29,5 +34,9 @@ func Run(addr string) error {
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static") compressed.Static("/static", "./static")
batchSize := helpers.EnvInt("WARPBOX_THUMBNAIL_BATCH_SIZE", 10, 1)
intervalSeconds := helpers.EnvInt("WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 30, 1)
boxstore.StartThumbnailWorker(batchSize, time.Duration(intervalSeconds)*time.Second)
return router.Run(addr) return router.Run(addr)
} }

View File

@@ -124,6 +124,14 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.box-file.has-thumbnail .box-file-icon {
width: 40px;
height: 32px;
object-fit: cover;
background: #ffffff;
border: 1px solid #808080;
}
.box-file-name, .box-file-name,
.box-file-meta { .box-file-meta {
width: 100%; width: 100%;

View File

@@ -286,6 +286,14 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
.upload-file-row.has-thumbnail .upload-file-icon {
width: 20px;
height: 20px;
object-fit: cover;
background: #ffffff;
border: 1px solid #808080;
}
.upload-file-name, .upload-file-name,
.upload-file-size { .upload-file-size {
min-width: 0; min-width: 0;

View File

@@ -19,6 +19,14 @@ let selectedFiles = [];
let statusTimer = null; let statusTimer = null;
let shareURL = ""; let shareURL = "";
function revokePreviewURLs() {
selectedFiles.forEach((selectedFile) => {
if (selectedFile.previewURL) {
URL.revokeObjectURL(selectedFile.previewURL);
}
});
}
function formatBytes(bytes) { function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB"]; const units = ["B", "KB", "MB", "GB"];
let size = bytes; let size = bytes;
@@ -167,10 +175,11 @@ function setRowProgress(row, percent) {
function createFileRow(selectedFile) { function createFileRow(selectedFile) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "upload-file-row"; row.className = "upload-file-row";
row.classList.toggle("has-thumbnail", Boolean(selectedFile.previewURL));
const icon = document.createElement("img"); const icon = document.createElement("img");
icon.className = "upload-file-icon"; icon.className = "upload-file-icon";
icon.src = iconForFile(selectedFile.file); icon.src = selectedFile.previewURL || iconForFile(selectedFile.file);
icon.alt = ""; icon.alt = "";
icon.setAttribute("aria-hidden", "true"); icon.setAttribute("aria-hidden", "true");
@@ -197,8 +206,10 @@ function createFileRow(selectedFile) {
} }
function updateSelectedFiles(files) { function updateSelectedFiles(files) {
revokePreviewURLs();
selectedFiles = Array.from(files || []).map((file) => ({ selectedFiles = Array.from(files || []).map((file) => ({
file, file,
previewURL: file.type.startsWith("image/") ? URL.createObjectURL(file) : "",
loaded: 0, loaded: 0,
row: null, row: null,
uploaded: false, uploaded: false,
@@ -438,7 +449,10 @@ if (uploadForm) {
selectedFile.boxID = box.box_id; selectedFile.boxID = box.box_id;
selectedFile.boxFile = box.files[index]; selectedFile.boxFile = box.files[index];
const icon = selectedFile.row.querySelector(".upload-file-icon"); const icon = selectedFile.row.querySelector(".upload-file-icon");
if (icon && selectedFile.boxFile.icon_path) { if (icon && selectedFile.boxFile.thumbnail_path) {
selectedFile.row.classList.add("has-thumbnail");
icon.src = selectedFile.boxFile.thumbnail_path;
} else if (icon && selectedFile.boxFile.icon_path && !selectedFile.previewURL) {
icon.src = selectedFile.boxFile.icon_path; icon.src = selectedFile.boxFile.icon_path;
} }
}); });
@@ -489,3 +503,5 @@ if (shareButton) {
} }
}); });
} }
window.addEventListener("beforeunload", revokePreviewURLs);

View File

@@ -16,12 +16,14 @@ function updateBoxFile(file) {
} }
const meta = item.querySelector(".box-file-meta"); const meta = item.querySelector(".box-file-meta");
const icon = item.querySelector(".box-file-icon");
const isComplete = file.status === "complete"; const isComplete = file.status === "complete";
const isFailed = file.status === "failed"; const isFailed = file.status === "failed";
item.classList.toggle("is-complete", isComplete); item.classList.toggle("is-complete", isComplete);
item.classList.toggle("is-failed", isFailed); item.classList.toggle("is-failed", isFailed);
item.classList.toggle("is-loading", !isComplete && !isFailed); item.classList.toggle("is-loading", !isComplete && !isFailed);
item.classList.toggle("has-thumbnail", Boolean(file.thumbnail_path));
item.dataset.status = file.status; item.dataset.status = file.status;
item.title = file.title; item.title = file.title;
@@ -38,6 +40,10 @@ function updateBoxFile(file) {
if (meta) { if (meta) {
meta.textContent = `${file.status_label} · ${file.size_label}`; meta.textContent = `${file.status_label} · ${file.size_label}`;
} }
if (icon) {
icon.src = file.thumbnail_path || file.icon_path;
}
} }
async function refreshBoxStatus() { async function refreshBoxStatus() {
@@ -59,7 +65,11 @@ async function refreshBoxStatus() {
boxStatus.textContent = `${completeCount}/${result.files.length} ready`; boxStatus.textContent = `${completeCount}/${result.files.length} ready`;
} }
return result.files.some((file) => file.status === "pending" || file.status === "uploading"); return result.files.some((file) => {
const isUploading = file.status === "pending" || file.status === "uploading";
const isWaitingForThumbnail = file.status === "complete" && !file.thumbnail_status && !file.thumbnail_path;
return isUploading || isWaitingForThumbnail || file.thumbnail_status === "processing";
});
} }
if (boxPanel) { if (boxPanel) {

View File

@@ -57,8 +57,8 @@
{{ if .Files }} {{ if .Files }}
<div class="box-file-grid" aria-label="Uploaded files"> <div class="box-file-grid" aria-label="Uploaded files">
{{ range .Files }} {{ range .Files }}
<a class="box-file {{ if .IsComplete }}is-complete{{ else if eq .Status "failed" }}is-failed{{ else }}is-loading{{ end }}" href="{{ if .IsComplete }}{{ .DownloadPath }}{{ else }}#{{ end }}" title="{{ .Title }}" data-file-id="{{ .ID }}" data-status="{{ .Status }}" {{ if .IsComplete }}download{{ else }}aria-disabled="true"{{ end }}> <a class="box-file {{ if .IsComplete }}is-complete{{ else if eq .Status "failed" }}is-failed{{ else }}is-loading{{ end }} {{ if .ThumbnailURL }}has-thumbnail{{ end }}" href="{{ if .IsComplete }}{{ .DownloadPath }}{{ else }}#{{ end }}" title="{{ .Title }}" data-file-id="{{ .ID }}" data-status="{{ .Status }}" {{ if .IsComplete }}download{{ else }}aria-disabled="true"{{ end }}>
<img class="box-file-icon" src="{{ .IconPath }}" alt="" aria-hidden="true"> <img class="box-file-icon" src="{{ if .ThumbnailURL }}{{ .ThumbnailURL }}{{ else }}{{ .IconPath }}{{ end }}" alt="" aria-hidden="true">
<span class="box-file-name">{{ .Name }}</span> <span class="box-file-name">{{ .Name }}</span>
<span class="box-file-meta">{{ .StatusLabel }} · {{ .SizeLabel }}</span> <span class="box-file-meta">{{ .StatusLabel }} · {{ .SizeLabel }}</span>
</a> </a>