From 698166d23d3f5e7e1030f63bb328371c6c20f8a2 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 27 Apr 2026 17:33:52 +0300 Subject: [PATCH] feat(server): track upload status via manifest and /status API - Persist per-box file metadata in a .warpbox.json manifest, including IDs and status fields (pending/uploading/complete/failed) - Add GET /box/:id/status to return current file states for clients polling upload progress - Update upload handling to mark failures and completion in the manifest and decorate responses - Add CSS states for loading/failed files and disable interactions for unavailable itemsfeat(server): track upload status via manifest and /status API - Persist per-box file metadata in a .warpbox.json manifest, including IDs and status fields (pending/uploading/complete/failed) - Add GET /box/:id/status to return current file states for clients polling upload progress - Update upload handling to mark failures and completion in the manifest and decorate responses - Add CSS states for loading/failed files and disable interactions for unavailable items --- lib/server/server.go | 373 ++++++++++++++++++++++++++++++++++++++++-- static/css/box.css | 32 ++++ static/css/upload.css | 15 ++ static/js/app.js | 76 ++++++++- static/js/box.js | 72 ++++++++ templates/box.html | 7 +- 6 files changed, 547 insertions(+), 28 deletions(-) create mode 100644 static/js/box.js diff --git a/lib/server/server.go b/lib/server/server.go index c4e1348..008e075 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -3,6 +3,7 @@ package server import ( "archive/zip" "crypto/rand" + "encoding/json" "encoding/hex" "fmt" "io" @@ -13,20 +14,53 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" ) -const uploadRoot = "data/uploads" +const ( + uploadRoot = "data/uploads" + boxManifestFile = ".warpbox.json" + fileStatusFailed = "failed" + fileStatusReady = "complete" + fileStatusWait = "pending" + fileStatusWork = "uploading" +) + +var boxManifestMu sync.Mutex type boxFile struct { - Name string - Size int64 - SizeLabel string - MimeType string - IconPath string - DownloadPath string + 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"` +} + +type boxManifest struct { + Files []boxFile `json:"files"` +} + +type createBoxRequest struct { + Files []createBoxFileRequest `json:"files"` +} + +type createBoxFileRequest struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +type updateFileStatusRequest struct { + Status string `json:"status"` } func Run(addr string) error { @@ -58,6 +92,25 @@ func Run(addr string) error { }) }) + router.GET("/box/:id/status", func(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) + return + } + + files, err := listBoxFiles(boxID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "box_id": boxID, + "files": files, + }) + }) + router.GET("/box/:id/download", func(ctx *gin.Context) { boxID := ctx.Param("id") if !validBoxID(boxID) { @@ -78,6 +131,10 @@ func Run(addr string) error { defer zipWriter.Close() for _, file := range files { + if !file.IsComplete { + continue + } + if err := addFileToZip(zipWriter, boxID, file.Name); err != nil { ctx.Status(http.StatusInternalServerError) return @@ -119,12 +176,77 @@ func Run(addr string) error { return } + var request createBoxRequest + if err := ctx.ShouldBindJSON(&request); err != nil && err != io.EOF { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"}) + return + } + + files, err := createBoxManifest(boxID, request.Files) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ctx.JSON(http.StatusOK, gin.H{ "box_id": boxID, "box_url": "/box/" + boxID, + "files": files, }) }) + router.POST("/box/:id/files/:file_id/upload", func(ctx *gin.Context) { + boxID := ctx.Param("id") + fileID := ctx.Param("file_id") + if !validBoxID(boxID) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) + return + } + + file, err := ctx.FormFile("file") + if err != nil { + markManifestFileStatus(boxID, fileID, fileStatusFailed) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) + return + } + + savedFile, err := saveManifestUploadedFile(ctx, boxID, fileID, file) + if err != nil { + markManifestFileStatus(boxID, fileID, fileStatusFailed) + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "box_id": boxID, + "box_url": "/box/" + boxID, + "file": savedFile, + }) + }) + + router.POST("/box/:id/files/:file_id/status", func(ctx *gin.Context) { + boxID := ctx.Param("id") + fileID := ctx.Param("file_id") + if !validBoxID(boxID) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) + return + } + + var request updateFileStatusRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"}) + return + } + + file, err := markManifestFileStatus(boxID, fileID, request.Status) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"file": file}) + }) + router.POST("/box/:id/upload", func(ctx *gin.Context) { boxID := ctx.Param("id") if !validBoxID(boxID) { @@ -209,6 +331,15 @@ func newBoxID() (string, error) { return hex.EncodeToString(bytes), nil } +func newFileID() (string, error) { + bytes := make([]byte, 8) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + return hex.EncodeToString(bytes), nil +} + func validBoxID(boxID string) bool { if len(boxID) != 32 { return false @@ -227,7 +358,20 @@ func boxPath(boxID string) string { return filepath.Join(uploadRoot, boxID) } +func manifestPath(boxID string) string { + return filepath.Join(boxPath(boxID), boxManifestFile) +} + func listBoxFiles(boxID string) ([]boxFile, error) { + if manifest, err := readBoxManifest(boxID); err == nil && len(manifest.Files) > 0 { + files := make([]boxFile, 0, len(manifest.Files)) + for _, file := range manifest.Files { + files = append(files, decorateBoxFile(boxID, file)) + } + + return files, nil + } + entries, err := os.ReadDir(boxPath(boxID)) if err != nil { return nil, err @@ -235,7 +379,7 @@ func listBoxFiles(boxID string) ([]boxFile, error) { files := make([]boxFile, 0, len(entries)) for _, entry := range entries { - if entry.IsDir() { + if entry.IsDir() || entry.Name() == boxManifestFile { continue } @@ -246,19 +390,160 @@ func listBoxFiles(boxID string) ([]boxFile, error) { name := entry.Name() mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), name) - files = append(files, boxFile{ - Name: name, - Size: info.Size(), - SizeLabel: formatBytes(info.Size()), - MimeType: mimeType, - IconPath: iconForMimeType(mimeType, name), - DownloadPath: "/box/" + boxID + "/files/" + url.PathEscape(name), - }) + files = append(files, decorateBoxFile(boxID, boxFile{ + ID: name, + Name: name, + Size: info.Size(), + MimeType: mimeType, + Status: fileStatusReady, + })) } return files, nil } +func createBoxManifest(boxID string, requests []createBoxFileRequest) ([]boxFile, error) { + usedNames := make(map[string]int, len(requests)) + files := make([]boxFile, 0, len(requests)) + + for _, request := range requests { + filename, ok := safeFilename(request.Name) + if !ok { + return nil, fmt.Errorf("Invalid filename") + } + + filename = uniqueManifestFilename(filename, usedNames) + fileID, err := newFileID() + if err != nil { + return nil, fmt.Errorf("Could not create file id") + } + + mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + files = append(files, boxFile{ + ID: fileID, + Name: filename, + Size: request.Size, + MimeType: mimeType, + Status: fileStatusWait, + }) + } + + manifest := boxManifest{Files: files} + if err := writeBoxManifest(boxID, manifest); err != nil { + return nil, err + } + + decoratedFiles := make([]boxFile, 0, len(files)) + for _, file := range files { + decoratedFiles = append(decoratedFiles, decorateBoxFile(boxID, file)) + } + + return decoratedFiles, nil +} + +func readBoxManifest(boxID string) (boxManifest, error) { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + return readBoxManifestUnlocked(boxID) +} + +func readBoxManifestUnlocked(boxID string) (boxManifest, error) { + var manifest boxManifest + data, err := os.ReadFile(manifestPath(boxID)) + if err != nil { + return manifest, err + } + + if err := json.Unmarshal(data, &manifest); err != nil { + return manifest, err + } + + return manifest, nil +} + +func writeBoxManifest(boxID string, manifest boxManifest) error { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + return writeBoxManifestUnlocked(boxID, manifest) +} + +func writeBoxManifestUnlocked(boxID string, manifest boxManifest) error { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + + return os.WriteFile(manifestPath(boxID), data, 0644) +} + +func markManifestFileStatus(boxID string, fileID string, status string) (boxFile, error) { + if status != fileStatusWait && status != fileStatusWork && status != fileStatusReady && status != fileStatusFailed { + return boxFile{}, fmt.Errorf("Invalid file status") + } + + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + manifest, err := readBoxManifestUnlocked(boxID) + if err != nil { + return boxFile{}, err + } + + for index, file := range manifest.Files { + if file.ID != fileID { + continue + } + + manifest.Files[index].Status = status + if err := writeBoxManifestUnlocked(boxID, manifest); err != nil { + return boxFile{}, err + } + + return decorateBoxFile(boxID, manifest.Files[index]), nil + } + + return boxFile{}, fmt.Errorf("File not found") +} + +func decorateBoxFile(boxID string, file boxFile) boxFile { + if file.MimeType == "" { + file.MimeType = mimeTypeForFile(filepath.Join(boxPath(boxID), file.Name), file.Name) + } + + if file.SizeLabel == "" { + file.SizeLabel = formatBytes(file.Size) + } + + file.IconPath = iconForMimeType(file.MimeType, file.Name) + file.DownloadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.Name) + file.UploadPath = "/box/" + boxID + "/files/" + url.PathEscape(file.ID) + "/upload" + file.IsComplete = file.Status == fileStatusReady + + switch file.Status { + case fileStatusReady: + file.StatusLabel = "Ready" + file.Title = "Download " + file.Name + case fileStatusFailed: + file.StatusLabel = "Failed" + file.Title = "Failed to upload" + case fileStatusWork: + file.StatusLabel = "Loading" + file.Title = "Loading" + default: + file.Status = fileStatusWait + file.StatusLabel = "Waiting" + file.Title = "Loading" + } + + return file +} + func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error { path := filepath.Join(boxPath(boxID), filename) source, err := os.Open(path) @@ -276,6 +561,49 @@ func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error { return err } +func saveManifestUploadedFile(ctx *gin.Context, boxID string, fileID string, file *multipart.FileHeader) (boxFile, error) { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + manifest, err := readBoxManifestUnlocked(boxID) + if err != nil { + return boxFile{}, err + } + + fileIndex := -1 + for index, manifestFile := range manifest.Files { + if manifestFile.ID == fileID { + fileIndex = index + break + } + } + + if fileIndex < 0 { + return boxFile{}, fmt.Errorf("File not found") + } + + filename := manifest.Files[fileIndex].Name + if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { + return boxFile{}, fmt.Errorf("Could not prepare upload box") + } + + destination := filepath.Join(boxPath(boxID), filename) + if err := ctx.SaveUploadedFile(file, destination); err != nil { + manifest.Files[fileIndex].Status = fileStatusFailed + writeBoxManifestUnlocked(boxID, manifest) + return boxFile{}, fmt.Errorf("Could not save uploaded file") + } + + manifest.Files[fileIndex].Size = file.Size + manifest.Files[fileIndex].MimeType = mimeTypeForFile(destination, filename) + manifest.Files[fileIndex].Status = fileStatusReady + if err := writeBoxManifestUnlocked(boxID, manifest); err != nil { + return boxFile{}, err + } + + return decorateBoxFile(boxID, manifest.Files[fileIndex]), nil +} + func saveUploadedFile(ctx *gin.Context, boxID string, file *multipart.FileHeader) (gin.H, error) { filename, ok := safeFilename(file.Filename) if !ok { @@ -320,6 +648,19 @@ func uniqueFilename(directory string, filename string) string { } } +func uniqueManifestFilename(filename string, usedNames map[string]int) string { + count := usedNames[filename] + usedNames[filename] = count + 1 + + if count == 0 { + return filename + } + + extension := filepath.Ext(filename) + base := strings.TrimSuffix(filename, extension) + return fmt.Sprintf("%s-%d%s", base, count+1, extension) +} + func mimeTypeForFile(path string, filename string) string { if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" { return mimeType diff --git a/static/css/box.css b/static/css/box.css index 4d3fd14..d545d7a 100644 --- a/static/css/box.css +++ b/static/css/box.css @@ -74,6 +74,28 @@ text-decoration: none; } +.box-file.is-loading, +.box-file.is-failed { + color: #666666; + filter: grayscale(1); +} + +.box-file.is-loading { + animation: box-loading-pulse 900ms steps(2, end) infinite; +} + +.box-file.is-failed { + opacity: 0.58; +} + +.box-file.is-failed .box-file-name::after { + content: " (failed)"; +} + +.box-file[aria-disabled="true"] { + cursor: default; +} + .box-file:hover, .box-file:focus-visible { color: #ffffff; @@ -99,6 +121,16 @@ white-space: nowrap; } +@keyframes box-loading-pulse { + 0% { + opacity: 0.48; + } + + 100% { + opacity: 0.82; + } +} + .box-file-name { font-size: 13px; line-height: 13px; diff --git a/static/css/upload.css b/static/css/upload.css index 97e938e..f606c7a 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -204,6 +204,11 @@ background: #f7f7f7; } +.upload-file-row.is-uploading, +.upload-file-row.is-processing { + animation: upload-row-loading 900ms steps(2, end) infinite; +} + .upload-file-icon { grid-row: 1 / 3; width: 16px; @@ -269,6 +274,16 @@ background: #800000; } +@keyframes upload-row-loading { + 0% { + background-color: #ffffff; + } + + 100% { + background-color: #e6e6e6; + } +} + .upload-actions { display: flex; justify-content: flex-end; diff --git a/static/js/app.js b/static/js/app.js index 5803d22..f1d4008 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -98,7 +98,9 @@ function setOverallProgress(percent) { function updateOverallProgress() { const totalBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.file.size, 0); const loadedBytes = selectedFiles.reduce((total, selectedFile) => total + selectedFile.loaded, 0); - setOverallProgress(totalBytes > 0 ? (loadedBytes / totalBytes) * 100 : 0); + const uploadedCount = selectedFiles.filter((selectedFile) => selectedFile.uploaded).length; + const percent = totalBytes > 0 ? (loadedBytes / totalBytes) * 100 : 0; + setOverallProgress(percent >= 100 && uploadedCount < selectedFiles.length ? 99 : percent); } function updateFileCount() { @@ -186,7 +188,18 @@ function updateSelectedFiles(files) { } async function createBox() { - const response = await fetch("/box", { method: "POST" }); + const response = await fetch("/box", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + files: selectedFiles.map((selectedFile) => ({ + name: selectedFile.file.name, + size: selectedFile.file.size, + })), + }), + }); if (!response.ok) { throw new Error("Could not create upload box"); } @@ -194,16 +207,32 @@ async function createBox() { return response.json(); } +async function markFileStatus(selectedFile, status) { + if (!selectedFile.boxFile) { + return; + } + + await fetch(`/box/${selectedFile.boxID}/files/${selectedFile.boxFile.id}/status`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ status }), + }); +} + function uploadFile(boxID, selectedFile, onComplete) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append("file", selectedFile.file); - xhr.open("POST", `/box/${boxID}/upload`); + xhr.open("POST", selectedFile.boxFile.upload_path); xhr.upload.addEventListener("loadstart", () => { selectedFile.loaded = 0; + selectedFile.row.classList.add("is-uploading"); + selectedFile.row.title = "Loading"; updateOverallProgress(); setRowProgress(selectedFile.row, 2); }); @@ -215,20 +244,33 @@ function uploadFile(boxID, selectedFile, onComplete) { selectedFile.loaded = Math.min(event.loaded, selectedFile.file.size); updateOverallProgress(); - setRowProgress(selectedFile.row, (event.loaded / event.total) * 100); + const percent = (event.loaded / event.total) * 100; + if (percent >= 100) { + selectedFile.row.classList.add("is-processing"); + selectedFile.row.title = "Loading"; + setRowProgress(selectedFile.row, 99); + return; + } + + setRowProgress(selectedFile.row, percent); }); xhr.addEventListener("load", () => { if (xhr.status < 200 || xhr.status >= 300) { selectedFile.failed = true; + selectedFile.row.classList.remove("is-uploading", "is-processing"); selectedFile.row.classList.add("is-failed"); + selectedFile.row.title = "Failed to upload"; + markFileStatus(selectedFile, "failed"); reject(new Error("Upload failed")); return; } selectedFile.uploaded = true; selectedFile.loaded = selectedFile.file.size; + selectedFile.row.classList.remove("is-uploading", "is-processing"); selectedFile.row.classList.add("is-uploaded"); + selectedFile.row.title = "Uploaded"; updateOverallProgress(); setRowProgress(selectedFile.row, 100); onComplete(); @@ -237,10 +279,23 @@ function uploadFile(boxID, selectedFile, onComplete) { xhr.addEventListener("error", () => { selectedFile.failed = true; + selectedFile.row.classList.remove("is-uploading", "is-processing"); selectedFile.row.classList.add("is-failed"); + selectedFile.row.title = "Failed to upload"; + markFileStatus(selectedFile, "failed"); reject(new Error("Upload failed")); }); + xhr.addEventListener("abort", () => { + selectedFile.failed = true; + selectedFile.row.classList.remove("is-uploading", "is-processing"); + selectedFile.row.classList.add("is-failed"); + selectedFile.row.title = "Failed to upload"; + markFileStatus(selectedFile, "failed"); + reject(new Error("Upload cancelled")); + }); + + markFileStatus(selectedFile, "uploading"); xhr.send(formData); }); } @@ -293,7 +348,8 @@ if (uploadForm) { selectedFile.uploaded = false; selectedFile.failed = false; selectedFile.loaded = 0; - selectedFile.row.classList.remove("is-uploaded", "is-failed"); + selectedFile.row.classList.remove("is-uploaded", "is-failed", "is-uploading", "is-processing"); + selectedFile.row.title = ""; setRowProgress(selectedFile.row, 0); }); @@ -305,6 +361,12 @@ if (uploadForm) { try { const box = await createBox(); setBoxStatus(box.box_url); + setBoxLink(box.box_url); + + selectedFiles.forEach((selectedFile, index) => { + selectedFile.boxID = box.box_id; + selectedFile.boxFile = box.files[index]; + }); await Promise.allSettled(selectedFiles.map((selectedFile) => { return uploadFile(box.box_id, selectedFile, () => { @@ -315,14 +377,10 @@ if (uploadForm) { stopStatusAnimation(); const failedCount = selectedFiles.filter((selectedFile) => selectedFile.failed).length; if (failedCount > 0) { - if (completedCount > 0) { - setBoxLink(box.box_url); - } updateStatus(`${completedCount}/${totalCount} Uploaded, ${failedCount} failed`); return; } - setBoxLink(box.box_url); setOverallProgress(100); updateStatus(`${completedCount}/${totalCount} Uploaded`); } catch (error) { diff --git a/static/js/box.js b/static/js/box.js new file mode 100644 index 0000000..816a00e --- /dev/null +++ b/static/js/box.js @@ -0,0 +1,72 @@ +const boxPanel = document.querySelector(".box-panel[data-box-id]"); +const boxStatus = document.querySelector(".box-statusbar span:first-child"); + +document.querySelectorAll('.box-file[aria-disabled="true"]').forEach((item) => { + item.addEventListener("click", (event) => { + if (item.getAttribute("aria-disabled") === "true") { + event.preventDefault(); + } + }); +}); + +function updateBoxFile(file) { + const item = document.querySelector(`.box-file[data-file-id="${file.id}"]`); + if (!item) { + return; + } + + const meta = item.querySelector(".box-file-meta"); + 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.dataset.status = file.status; + item.title = file.title; + + if (isComplete) { + item.href = file.download_path; + item.setAttribute("download", ""); + item.removeAttribute("aria-disabled"); + } else { + item.href = "#"; + item.removeAttribute("download"); + item.setAttribute("aria-disabled", "true"); + } + + if (meta) { + meta.textContent = `${file.status_label} · ${file.size_label}`; + } +} + +async function refreshBoxStatus() { + if (!boxPanel) { + return false; + } + + const boxID = boxPanel.dataset.boxId; + const response = await fetch(`/box/${boxID}/status`); + if (!response.ok) { + return false; + } + + const result = await response.json(); + result.files.forEach(updateBoxFile); + + if (boxStatus) { + const completeCount = result.files.filter((file) => file.status === "complete").length; + boxStatus.textContent = `${completeCount}/${result.files.length} ready`; + } + + return result.files.some((file) => file.status === "pending" || file.status === "uploading"); +} + +if (boxPanel) { + const timer = setInterval(async () => { + const hasLoadingFiles = await refreshBoxStatus(); + if (!hasLoadingFiles) { + clearInterval(timer); + } + }, 1500); +} diff --git a/templates/box.html b/templates/box.html index daacd02..c7e0434 100644 --- a/templates/box.html +++ b/templates/box.html @@ -40,14 +40,14 @@ /box/{{ .BoxID }} -
+
{{ if .Files }} @@ -62,5 +62,6 @@
+