From f1600faa8d9bdf6a1625d0c1a60122ccfb283848 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Tue, 28 Apr 2026 18:44:16 +0300 Subject: [PATCH] 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 --- lib/boxstore/store.go | 3 + lib/boxstore/store_test.go | 44 +++++++ lib/boxstore/thumbnails.go | 246 +++++++++++++++++++++++++++++++++++++ lib/models/models.go | 34 +++-- lib/routing/routes.go | 2 + lib/server/handlers.go | 27 ++++ lib/server/server.go | 9 ++ static/css/box.css | 8 ++ static/css/upload.css | 8 ++ static/js/app.js | 20 ++- static/js/box.js | 12 +- templates/box.html | 4 +- 12 files changed, 400 insertions(+), 17 deletions(-) create mode 100644 lib/boxstore/store_test.go create mode 100644 lib/boxstore/thumbnails.go diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go index 1c37bf0..cc7046e 100644 --- a/lib/boxstore/store.go +++ b/lib/boxstore/store.go @@ -329,6 +329,9 @@ func DecorateFile(boxID string, file models.BoxFile) models.BoxFile { } 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.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload" file.IsComplete = file.Status == models.FileStatusReady diff --git a/lib/boxstore/store_test.go b/lib/boxstore/store_test.go new file mode 100644 index 0000000..7abbb1f --- /dev/null +++ b/lib/boxstore/store_test.go @@ -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) + } +} diff --git a/lib/boxstore/thumbnails.go b/lib/boxstore/thumbnails.go new file mode 100644 index 0000000..0509913 --- /dev/null +++ b/lib/boxstore/thumbnails.go @@ -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 +} diff --git a/lib/models/models.go b/lib/models/models.go index 5ff3965..5155098 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -9,6 +9,13 @@ const ( FileStatusWork = "uploading" ) +const ( + ThumbnailStatusFailed = "failed" + ThumbnailStatusProcessing = "processing" + ThumbnailStatusReady = "ready" + ThumbnailStatusUnsupported = "unsupported" +) + type RetentionOption struct { Key string `json:"key"` Label string `json:"label"` @@ -16,18 +23,21 @@ type RetentionOption struct { } type BoxFile struct { - ID string `json:"id"` - Name string `json:"name"` - Size int64 `json:"size"` - SizeLabel string `json:"size_label"` - MimeType string `json:"mime_type"` - Status string `json:"status"` - StatusLabel string `json:"status_label"` - Title string `json:"title"` - IconPath string `json:"icon_path"` - DownloadPath string `json:"download_path"` - UploadPath string `json:"upload_path"` - IsComplete bool `json:"is_complete"` + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + SizeLabel string `json:"size_label"` + MimeType string `json:"mime_type"` + Status string `json:"status"` + StatusLabel string `json:"status_label"` + Title string `json:"title"` + IconPath string `json:"icon_path"` + ThumbnailPath *string `json:"thumbnail_path"` + ThumbnailStatus string `json:"thumbnail_status,omitempty"` + ThumbnailURL string `json:"-"` + DownloadPath string `json:"download_path"` + UploadPath string `json:"upload_path"` + IsComplete bool `json:"is_complete"` } type BoxManifest struct { diff --git a/lib/routing/routes.go b/lib/routing/routes.go index e31c9ed..b910f0e 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -10,6 +10,7 @@ type Handlers struct { BoxStatus gin.HandlerFunc DownloadBox gin.HandlerFunc DownloadFile gin.HandlerFunc + DownloadThumbnail gin.HandlerFunc CreateBox gin.HandlerFunc ManifestFileUpload 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/download", handlers.DownloadBox) 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/:id/login", handlers.BoxLoginPost) diff --git a/lib/server/handlers.go b/lib/server/handlers.go index 50c893b..23de7f4 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -208,6 +208,33 @@ func handleDownloadFile(ctx *gin.Context) { 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) { boxID, err := boxstore.NewBoxID() if err != nil { diff --git a/lib/server/server.go b/lib/server/server.go index 5683202..018755f 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,9 +1,13 @@ package server import ( + "time" + "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" + "warpbox/lib/boxstore" + "warpbox/lib/helpers" "warpbox/lib/routing" ) @@ -19,6 +23,7 @@ func Run(addr string) error { BoxStatus: handleBoxStatus, DownloadBox: handleDownloadBox, DownloadFile: handleDownloadFile, + DownloadThumbnail: handleDownloadThumbnail, CreateBox: handleCreateBox, ManifestFileUpload: handleManifestFileUpload, FileStatusUpdate: handleFileStatusUpdate, @@ -29,5 +34,9 @@ func Run(addr string) error { compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) 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) } diff --git a/static/css/box.css b/static/css/box.css index e17dc3a..b170cbb 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -124,6 +124,14 @@ 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-meta { width: 100%; diff --git a/static/css/upload.css b/static/css/upload.css index f74ff49..60bc550 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -286,6 +286,14 @@ 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-size { min-width: 0; diff --git a/static/js/app.js b/static/js/app.js index a7c8d58..24617a7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -19,6 +19,14 @@ let selectedFiles = []; let statusTimer = null; let shareURL = ""; +function revokePreviewURLs() { + selectedFiles.forEach((selectedFile) => { + if (selectedFile.previewURL) { + URL.revokeObjectURL(selectedFile.previewURL); + } + }); +} + function formatBytes(bytes) { const units = ["B", "KB", "MB", "GB"]; let size = bytes; @@ -167,10 +175,11 @@ function setRowProgress(row, percent) { function createFileRow(selectedFile) { const row = document.createElement("div"); row.className = "upload-file-row"; + row.classList.toggle("has-thumbnail", Boolean(selectedFile.previewURL)); const icon = document.createElement("img"); icon.className = "upload-file-icon"; - icon.src = iconForFile(selectedFile.file); + icon.src = selectedFile.previewURL || iconForFile(selectedFile.file); icon.alt = ""; icon.setAttribute("aria-hidden", "true"); @@ -197,8 +206,10 @@ function createFileRow(selectedFile) { } function updateSelectedFiles(files) { + revokePreviewURLs(); selectedFiles = Array.from(files || []).map((file) => ({ file, + previewURL: file.type.startsWith("image/") ? URL.createObjectURL(file) : "", loaded: 0, row: null, uploaded: false, @@ -438,7 +449,10 @@ if (uploadForm) { selectedFile.boxID = box.box_id; selectedFile.boxFile = box.files[index]; 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; } }); @@ -489,3 +503,5 @@ if (shareButton) { } }); } + +window.addEventListener("beforeunload", revokePreviewURLs); diff --git a/static/js/box.js b/static/js/box.js index 719ae89..f5e7dc1 100644 --- a/static/js/box.js +++ b/static/js/box.js @@ -16,12 +16,14 @@ function updateBoxFile(file) { } const meta = item.querySelector(".box-file-meta"); + const icon = item.querySelector(".box-file-icon"); const isComplete = file.status === "complete"; const isFailed = file.status === "failed"; item.classList.toggle("is-complete", isComplete); item.classList.toggle("is-failed", isFailed); item.classList.toggle("is-loading", !isComplete && !isFailed); + item.classList.toggle("has-thumbnail", Boolean(file.thumbnail_path)); item.dataset.status = file.status; item.title = file.title; @@ -38,6 +40,10 @@ function updateBoxFile(file) { if (meta) { meta.textContent = `${file.status_label} · ${file.size_label}`; } + + if (icon) { + icon.src = file.thumbnail_path || file.icon_path; + } } async function refreshBoxStatus() { @@ -59,7 +65,11 @@ async function refreshBoxStatus() { 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) { diff --git a/templates/box.html b/templates/box.html index 7e9dfbf..0019b55 100644 --- a/templates/box.html +++ b/templates/box.html @@ -57,8 +57,8 @@ {{ if .Files }}
{{ range .Files }} - - + + {{ .Name }} {{ .StatusLabel }} · {{ .SizeLabel }}