From cf90e08f98956abdf688a1c2481738daf5f9a40d Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 27 Apr 2026 17:49:19 +0300 Subject: [PATCH] refactor: extract models/routes and env-based server config - Move API request/response structs into new lib/models package - Centralize Gin route registration in lib/routing to simplify wiring - Add lib/server config helper to allow WARPBOX_BOX_POLL_INTERVAL_MS override - Improves modularity and makes polling behavior configurable per environmentrefactor: extract models/routes and env-based server config - Move API request/response structs into new lib/models package - Centralize Gin route registration in lib/routing to simplify wiring - Add lib/server config helper to allow WARPBOX_BOX_POLL_INTERVAL_MS override - Improves modularity and makes polling behavior configurable per environment --- lib/models/models.go | 33 ++ lib/routing/routes.go | 33 ++ lib/server/config.go | 20 + lib/server/constants.go | 12 + lib/server/files.go | 125 +++++++ lib/server/handlers.go | 243 +++++++++++++ lib/server/ids.go | 39 ++ lib/server/manifest.go | 177 +++++++++ lib/server/paths.go | 55 +++ lib/server/presentation.go | 111 ++++++ lib/server/server.go | 721 +------------------------------------ static/css/upload.css | 6 +- static/js/box.js | 16 +- templates/box.html | 2 +- templates/index.html | 8 +- 15 files changed, 878 insertions(+), 723 deletions(-) create mode 100644 lib/models/models.go create mode 100644 lib/routing/routes.go create mode 100644 lib/server/config.go create mode 100644 lib/server/constants.go create mode 100644 lib/server/files.go create mode 100644 lib/server/handlers.go create mode 100644 lib/server/ids.go create mode 100644 lib/server/manifest.go create mode 100644 lib/server/paths.go create mode 100644 lib/server/presentation.go diff --git a/lib/models/models.go b/lib/models/models.go new file mode 100644 index 0000000..094f0bf --- /dev/null +++ b/lib/models/models.go @@ -0,0 +1,33 @@ +package models + +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"` +} + +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"` +} diff --git a/lib/routing/routes.go b/lib/routing/routes.go new file mode 100644 index 0000000..2e21d5f --- /dev/null +++ b/lib/routing/routes.go @@ -0,0 +1,33 @@ +package routing + +import "github.com/gin-gonic/gin" + +type Handlers struct { + Index gin.HandlerFunc + ShowBox gin.HandlerFunc + BoxStatus gin.HandlerFunc + DownloadBox gin.HandlerFunc + DownloadFile gin.HandlerFunc + CreateBox gin.HandlerFunc + ManifestFileUpload gin.HandlerFunc + FileStatusUpdate gin.HandlerFunc + DirectBoxUpload gin.HandlerFunc + LegacyUpload gin.HandlerFunc +} + +func Register(router *gin.Engine, handlers Handlers) { + router.GET("/", handlers.Index) + + router.GET("/box/:id", handlers.ShowBox) + router.GET("/box/:id/status", handlers.BoxStatus) + router.GET("/box/:id/download", handlers.DownloadBox) + router.GET("/box/:id/files/:filename", handlers.DownloadFile) + + router.POST("/box", handlers.CreateBox) + router.POST("/box/:id/files/:file_id/upload", handlers.ManifestFileUpload) + router.POST("/box/:id/files/:file_id/status", handlers.FileStatusUpdate) + + // Legacy upload routes are kept for compatibility with older clients. + router.POST("/box/:id/upload", handlers.DirectBoxUpload) + router.POST("/upload", handlers.LegacyUpload) +} diff --git a/lib/server/config.go b/lib/server/config.go new file mode 100644 index 0000000..2ed05ba --- /dev/null +++ b/lib/server/config.go @@ -0,0 +1,20 @@ +package server + +import ( + "os" + "strconv" +) + +func boxPollingIntervalMS() int { + rawValue := os.Getenv("WARPBOX_BOX_POLL_INTERVAL_MS") + if rawValue == "" { + return boxPollInterval + } + + interval, err := strconv.Atoi(rawValue) + if err != nil || interval < 1000 { + return boxPollInterval + } + + return interval +} diff --git a/lib/server/constants.go b/lib/server/constants.go new file mode 100644 index 0000000..2ef6008 --- /dev/null +++ b/lib/server/constants.go @@ -0,0 +1,12 @@ +package server + +const ( + uploadRoot = "data/uploads" + boxManifestFile = ".warpbox.json" + boxPollInterval = 5000 + + fileStatusFailed = "failed" + fileStatusReady = "complete" + fileStatusWait = "pending" + fileStatusWork = "uploading" +) diff --git a/lib/server/files.go b/lib/server/files.go new file mode 100644 index 0000000..5977342 --- /dev/null +++ b/lib/server/files.go @@ -0,0 +1,125 @@ +package server + +import ( + "archive/zip" + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + + "github.com/gin-gonic/gin" + + "warpbox/lib/models" +) + +func listCompletedFilesFromDisk(boxID string) ([]models.BoxFile, error) { + entries, err := os.ReadDir(boxPath(boxID)) + if err != nil { + return nil, err + } + + files := make([]models.BoxFile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || entry.Name() == boxManifestFile { + continue + } + + info, err := entry.Info() + if err != nil { + return nil, err + } + + name := entry.Name() + mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), name) + files = append(files, decorateBoxFile(boxID, models.BoxFile{ + ID: name, + Name: name, + Size: info.Size(), + MimeType: mimeType, + Status: fileStatusReady, + })) + } + + return files, nil +} + +func addFileToZip(zipWriter *zip.Writer, boxID string, filename string) error { + path := filepath.Join(boxPath(boxID), filename) + source, err := os.Open(path) + if err != nil { + return err + } + defer source.Close() + + destination, err := zipWriter.Create(filename) + if err != nil { + return err + } + + _, err = io.Copy(destination, source) + return err +} + +func saveManifestUploadedFile(ctx *gin.Context, boxID string, fileID string, file *multipart.FileHeader) (models.BoxFile, error) { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + manifest, err := readBoxManifestUnlocked(boxID) + if err != nil { + return models.BoxFile{}, err + } + + fileIndex := -1 + for index, manifestFile := range manifest.Files { + if manifestFile.ID == fileID { + fileIndex = index + break + } + } + + if fileIndex < 0 { + return models.BoxFile{}, fmt.Errorf("File not found") + } + + filename := manifest.Files[fileIndex].Name + if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { + return models.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 models.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 models.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 { + return nil, fmt.Errorf("Invalid filename") + } + + boxPath := boxPath(boxID) + if err := os.MkdirAll(boxPath, 0755); err != nil { + return nil, fmt.Errorf("Could not prepare upload box") + } + + filename = uniqueFilename(boxPath, filename) + destination := filepath.Join(boxPath, filename) + if err := ctx.SaveUploadedFile(file, destination); err != nil { + return nil, fmt.Errorf("Could not save uploaded file") + } + + return gin.H{"name": filename, "size": file.Size}, nil +} diff --git a/lib/server/handlers.go b/lib/server/handlers.go new file mode 100644 index 0000000..f4a0a04 --- /dev/null +++ b/lib/server/handlers.go @@ -0,0 +1,243 @@ +package server + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + + "github.com/gin-gonic/gin" + + "warpbox/lib/models" +) + +func handleIndex(ctx *gin.Context) { + ctx.HTML(http.StatusOK, "index.html", gin.H{}) +} + +func handleShowBox(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.String(http.StatusBadRequest, "Invalid box id") + return + } + + files, err := listBoxFiles(boxID) + if err != nil { + ctx.String(http.StatusNotFound, "Box not found") + return + } + + ctx.HTML(http.StatusOK, "box.html", gin.H{ + "BoxID": boxID, + "Files": files, + "FileCount": len(files), + "DownloadAll": "/box/" + boxID + "/download", + "PollMS": boxPollingIntervalMS(), + }) +} + +func handleBoxStatus(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}) +} + +func handleDownloadBox(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.String(http.StatusBadRequest, "Invalid box id") + return + } + + files, err := listBoxFiles(boxID) + if err != nil { + ctx.String(http.StatusNotFound, "Box not found") + return + } + + ctx.Header("Content-Type", "application/zip") + ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) + + zipWriter := zip.NewWriter(ctx.Writer) + 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 + } + } +} + +func handleDownloadFile(ctx *gin.Context) { + boxID := ctx.Param("id") + filename, ok := safeFilename(ctx.Param("filename")) + if !validBoxID(boxID) || !ok { + ctx.String(http.StatusBadRequest, "Invalid file") + return + } + + path, ok := safeBoxFilePath(boxID, filename) + if !ok { + ctx.String(http.StatusBadRequest, "Invalid file") + return + } + + if _, err := os.Stat(path); err != nil { + ctx.String(http.StatusNotFound, "File not found") + return + } + + ctx.FileAttachment(path, filename) +} + +func handleCreateBox(ctx *gin.Context) { + boxID, err := newBoxID() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) + return + } + + if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) + return + } + + var request models.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}) +} + +func handleManifestFileUpload(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}) +} + +func handleFileStatusUpdate(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 models.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}) +} + +func handleDirectBoxUpload(ctx *gin.Context) { + boxID := ctx.Param("id") + if !validBoxID(boxID) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) + return + } + + file, err := ctx.FormFile("file") + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) + return + } + + savedFile, err := saveUploadedFile(ctx, boxID, file) + 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, "file": savedFile}) +} + +func handleLegacyUpload(ctx *gin.Context) { + form, err := ctx.MultipartForm() + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"}) + return + } + + files := form.File["files"] + if len(files) == 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"}) + return + } + + boxID, err := newBoxID() + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) + return + } + + if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) + return + } + + savedFiles := make([]gin.H, 0, len(files)) + for _, file := range files { + savedFile, err := saveUploadedFile(ctx, boxID, file) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + savedFiles = append(savedFiles, savedFile) + } + + ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles}) +} diff --git a/lib/server/ids.go b/lib/server/ids.go new file mode 100644 index 0000000..e3bb594 --- /dev/null +++ b/lib/server/ids.go @@ -0,0 +1,39 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "strings" +) + +func newBoxID() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + 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 + } + + for _, character := range boxID { + if !strings.ContainsRune("0123456789abcdef", character) { + return false + } + } + + return true +} diff --git a/lib/server/manifest.go b/lib/server/manifest.go new file mode 100644 index 0000000..468e7c4 --- /dev/null +++ b/lib/server/manifest.go @@ -0,0 +1,177 @@ +package server + +import ( + "encoding/json" + "fmt" + "mime" + "os" + "path/filepath" + "strings" + "sync" + + "warpbox/lib/models" +) + +var boxManifestMu sync.Mutex + +func listBoxFiles(boxID string) ([]models.BoxFile, error) { + if manifest, err := reconcileBoxManifest(boxID); err == nil && len(manifest.Files) > 0 { + files := make([]models.BoxFile, 0, len(manifest.Files)) + for _, file := range manifest.Files { + files = append(files, decorateBoxFile(boxID, file)) + } + + return files, nil + } + + return listCompletedFilesFromDisk(boxID) +} + +func reconcileBoxManifest(boxID string) (models.BoxManifest, error) { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + manifest, err := readBoxManifestUnlocked(boxID) + if err != nil { + return manifest, err + } + + changed := false + for index, file := range manifest.Files { + path := filepath.Join(boxPath(boxID), file.Name) + info, err := os.Stat(path) + if err != nil { + continue + } + + if file.Status == fileStatusReady && file.Size == info.Size() { + continue + } + + // The manifest is the UI source of truth, but disk wins when an upload + // was saved and the final status write/response was interrupted. + manifest.Files[index].Size = info.Size() + manifest.Files[index].MimeType = mimeTypeForFile(path, file.Name) + manifest.Files[index].Status = fileStatusReady + changed = true + } + + if changed { + if err := writeBoxManifestUnlocked(boxID, manifest); err != nil { + return manifest, err + } + } + + return manifest, nil +} + +func createBoxManifest(boxID string, requests []models.CreateBoxFileRequest) ([]models.BoxFile, error) { + usedNames := make(map[string]int, len(requests)) + files := make([]models.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, models.BoxFile{ + ID: fileID, + Name: filename, + Size: request.Size, + MimeType: mimeType, + Status: fileStatusWait, + }) + } + + manifest := models.BoxManifest{Files: files} + if err := writeBoxManifest(boxID, manifest); err != nil { + return nil, err + } + + decoratedFiles := make([]models.BoxFile, 0, len(files)) + for _, file := range files { + decoratedFiles = append(decoratedFiles, decorateBoxFile(boxID, file)) + } + + return decoratedFiles, nil +} + +func markManifestFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) { + if status != fileStatusWait && status != fileStatusWork && status != fileStatusReady && status != fileStatusFailed { + return models.BoxFile{}, fmt.Errorf("Invalid file status") + } + + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + manifest, err := readBoxManifestUnlocked(boxID) + if err != nil { + return models.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 models.BoxFile{}, err + } + + return decorateBoxFile(boxID, manifest.Files[index]), nil + } + + return models.BoxFile{}, fmt.Errorf("File not found") +} + +func readBoxManifest(boxID string) (models.BoxManifest, error) { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + return readBoxManifestUnlocked(boxID) +} + +func readBoxManifestUnlocked(boxID string) (models.BoxManifest, error) { + var manifest models.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 models.BoxManifest) error { + boxManifestMu.Lock() + defer boxManifestMu.Unlock() + + return writeBoxManifestUnlocked(boxID, manifest) +} + +// Manifest writes are serialized because the browser can upload several files +// concurrently into the same box. Without this lock, status updates can race. +func writeBoxManifestUnlocked(boxID string, manifest models.BoxManifest) error { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + + return os.WriteFile(manifestPath(boxID), data, 0644) +} diff --git a/lib/server/paths.go b/lib/server/paths.go new file mode 100644 index 0000000..9ad4dde --- /dev/null +++ b/lib/server/paths.go @@ -0,0 +1,55 @@ +package server + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +func boxPath(boxID string) string { + return filepath.Join(uploadRoot, boxID) +} + +func manifestPath(boxID string) string { + return filepath.Join(boxPath(boxID), boxManifestFile) +} + +func safeFilename(name string) (string, bool) { + filename := filepath.Base(name) + filename = strings.TrimSpace(filename) + return filename, filename != "" && filename != "." && filename != string(filepath.Separator) +} + +func safeBoxFilePath(boxID string, filename string) (string, bool) { + path := filepath.Join(boxPath(boxID), filename) + return path, strings.HasPrefix(path, boxPath(boxID)+string(filepath.Separator)) +} + +func uniqueFilename(directory string, filename string) string { + if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) { + return filename + } + + extension := filepath.Ext(filename) + base := strings.TrimSuffix(filename, extension) + for count := 2; ; count++ { + candidate := base + "-" + strconv.Itoa(count) + extension + if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) { + return candidate + } + } +} + +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 base + "-" + strconv.Itoa(count+1) + extension +} diff --git a/lib/server/presentation.go b/lib/server/presentation.go new file mode 100644 index 0000000..3ec7cc5 --- /dev/null +++ b/lib/server/presentation.go @@ -0,0 +1,111 @@ +package server + +import ( + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "warpbox/lib/models" +) + +func decorateBoxFile(boxID string, file models.BoxFile) models.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 mimeTypeForFile(path string, filename string) string { + if mimeType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename))); mimeType != "" { + return mimeType + } + + file, err := os.Open(path) + if err != nil { + return "application/octet-stream" + } + defer file.Close() + + buffer := make([]byte, 512) + bytesRead, err := file.Read(buffer) + if err != nil && err != io.EOF { + return "application/octet-stream" + } + + return http.DetectContentType(buffer[:bytesRead]) +} + +func iconForMimeType(mimeType string, filename string) string { + extension := strings.ToLower(filepath.Ext(filename)) + + switch { + case extension == ".exe": + return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png" + case strings.HasPrefix(mimeType, "image/"): + return "/static/img/sprites/bitmap.png" + case strings.HasPrefix(mimeType, "video/"): + return "/static/img/icons/netshow_notransm-1.png" + case strings.HasPrefix(mimeType, "audio/"): + return "/static/img/icons/netshow_notransm-1.png" + case strings.HasPrefix(mimeType, "text/") || extension == ".md": + return "/static/img/sprites/notepad_file-1.png" + case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz": + return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png" + case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2": + return "/static/img/sprites/font.png" + case extension == ".pdf": + return "/static/img/sprites/journal.png" + case extension == ".html" || extension == ".css" || extension == ".js": + return "/static/img/sprites/frame_web-0.png" + default: + return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png" + } +} + +func formatBytes(bytes int64) string { + units := []string{"B", "KB", "MB", "GB"} + size := float64(bytes) + unitIndex := 0 + + for size >= 1024 && unitIndex < len(units)-1 { + size /= 1024 + unitIndex++ + } + + if unitIndex == 0 { + return fmt.Sprintf("%d %s", bytes, units[unitIndex]) + } + + return fmt.Sprintf("%.1f %s", size, units[unitIndex]) +} diff --git a/lib/server/server.go b/lib/server/server.go index 008e075..7ba2c66 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,319 +1,27 @@ package server import ( - "archive/zip" - "crypto/rand" - "encoding/json" - "encoding/hex" - "fmt" - "io" - "mime" - "mime/multipart" - "net/http" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" + + "warpbox/lib/routing" ) -const ( - uploadRoot = "data/uploads" - boxManifestFile = ".warpbox.json" - fileStatusFailed = "failed" - fileStatusReady = "complete" - fileStatusWait = "pending" - fileStatusWork = "uploading" -) - -var boxManifestMu sync.Mutex - -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"` -} - -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 { router := gin.Default() router.LoadHTMLGlob("templates/*.html") - router.GET("/", func(ctx *gin.Context) { - ctx.HTML(http.StatusOK, "index.html", gin.H{}) - }) - - router.GET("/box/:id", func(ctx *gin.Context) { - boxID := ctx.Param("id") - if !validBoxID(boxID) { - ctx.String(http.StatusBadRequest, "Invalid box id") - return - } - - files, err := listBoxFiles(boxID) - if err != nil { - ctx.String(http.StatusNotFound, "Box not found") - return - } - - ctx.HTML(http.StatusOK, "box.html", gin.H{ - "BoxID": boxID, - "Files": files, - "FileCount": len(files), - "DownloadAll": "/box/" + boxID + "/download", - }) - }) - - 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) { - ctx.String(http.StatusBadRequest, "Invalid box id") - return - } - - files, err := listBoxFiles(boxID) - if err != nil { - ctx.String(http.StatusNotFound, "Box not found") - return - } - - ctx.Header("Content-Type", "application/zip") - ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID)) - - zipWriter := zip.NewWriter(ctx.Writer) - 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 - } - } - }) - - router.GET("/box/:id/files/:filename", func(ctx *gin.Context) { - boxID := ctx.Param("id") - filename, ok := safeFilename(ctx.Param("filename")) - if !validBoxID(boxID) || !ok { - ctx.String(http.StatusBadRequest, "Invalid file") - return - } - - path := filepath.Join(boxPath(boxID), filename) - if !strings.HasPrefix(path, boxPath(boxID)+string(filepath.Separator)) { - ctx.String(http.StatusBadRequest, "Invalid file") - return - } - - if _, err := os.Stat(path); err != nil { - ctx.String(http.StatusNotFound, "File not found") - return - } - - ctx.FileAttachment(path, filename) - }) - - router.POST("/box", func(ctx *gin.Context) { - boxID, err := newBoxID() - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) - return - } - - if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) - 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) { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) - return - } - - file, err := ctx.FormFile("file") - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) - return - } - - savedFile, err := saveUploadedFile(ctx, boxID, file) - 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, - "file": savedFile, - }) - }) - - router.POST("/upload", func(ctx *gin.Context) { - form, err := ctx.MultipartForm() - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"}) - return - } - - files := form.File["files"] - if len(files) == 0 { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"}) - return - } - - boxID, err := newBoxID() - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"}) - return - } - - if err := os.MkdirAll(boxPath(boxID), 0755); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) - return - } - - savedFiles := make([]gin.H, 0, len(files)) - - for _, file := range files { - savedFile, err := saveUploadedFile(ctx, boxID, file) - if err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - savedFiles = append(savedFiles, savedFile) - } - - ctx.JSON(http.StatusOK, gin.H{ - "box_id": boxID, - "box_url": "/box/" + boxID, - "files": savedFiles, - }) + routing.Register(router, routing.Handlers{ + Index: handleIndex, + ShowBox: handleShowBox, + BoxStatus: handleBoxStatus, + DownloadBox: handleDownloadBox, + DownloadFile: handleDownloadFile, + CreateBox: handleCreateBox, + ManifestFileUpload: handleManifestFileUpload, + FileStatusUpdate: handleFileStatusUpdate, + DirectBoxUpload: handleDirectBoxUpload, + LegacyUpload: handleLegacyUpload, }) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) @@ -321,406 +29,3 @@ func Run(addr string) error { return router.Run(addr) } - -func newBoxID() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - - 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 - } - - for _, character := range boxID { - if !strings.ContainsRune("0123456789abcdef", character) { - return false - } - } - - return true -} - -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 - } - - files := make([]boxFile, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() || entry.Name() == boxManifestFile { - continue - } - - info, err := entry.Info() - if err != nil { - return nil, err - } - - name := entry.Name() - mimeType := mimeTypeForFile(filepath.Join(boxPath(boxID), name), 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) - if err != nil { - return err - } - defer source.Close() - - destination, err := zipWriter.Create(filename) - if err != nil { - return err - } - - _, err = io.Copy(destination, source) - 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 { - return nil, fmt.Errorf("Invalid filename") - } - - boxPath := boxPath(boxID) - if err := os.MkdirAll(boxPath, 0755); err != nil { - return nil, fmt.Errorf("Could not prepare upload box") - } - - filename = uniqueFilename(boxPath, filename) - destination := filepath.Join(boxPath, filename) - if err := ctx.SaveUploadedFile(file, destination); err != nil { - return nil, fmt.Errorf("Could not save uploaded file") - } - - return gin.H{ - "name": filename, - "size": file.Size, - }, nil -} - -func safeFilename(name string) (string, bool) { - filename := filepath.Base(name) - filename = strings.TrimSpace(filename) - return filename, filename != "" && filename != "." && filename != string(filepath.Separator) -} - -func uniqueFilename(directory string, filename string) string { - if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) { - return filename - } - - extension := filepath.Ext(filename) - base := strings.TrimSuffix(filename, extension) - for count := 2; ; count++ { - candidate := fmt.Sprintf("%s-%d%s", base, count, extension) - if _, err := os.Stat(filepath.Join(directory, candidate)); os.IsNotExist(err) { - return candidate - } - } -} - -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 - } - - file, err := os.Open(path) - if err != nil { - return "application/octet-stream" - } - defer file.Close() - - buffer := make([]byte, 512) - bytesRead, err := file.Read(buffer) - if err != nil && err != io.EOF { - return "application/octet-stream" - } - - return http.DetectContentType(buffer[:bytesRead]) -} - -func iconForMimeType(mimeType string, filename string) string { - extension := strings.ToLower(filepath.Ext(filename)) - - switch { - case extension == ".exe": - return "/static/img/icons/Program Files Icons - PNG/MSONSEXT.DLL_14_6-0.png" - case strings.HasPrefix(mimeType, "image/"): - return "/static/img/sprites/bitmap.png" - case strings.HasPrefix(mimeType, "video/"): - return "/static/img/icons/netshow_notransm-1.png" - case strings.HasPrefix(mimeType, "audio/"): - return "/static/img/icons/netshow_notransm-1.png" - case strings.HasPrefix(mimeType, "text/") || extension == ".md": - return "/static/img/sprites/notepad_file-1.png" - case strings.Contains(mimeType, "zip") || strings.Contains(mimeType, "compressed") || extension == ".rar" || extension == ".7z" || extension == ".tar" || extension == ".gz": - return "/static/img/icons/Windows Icons - PNG/zipfldr.dll_14_101-0.png" - case extension == ".ttf" || extension == ".otf" || extension == ".woff" || extension == ".woff2": - return "/static/img/sprites/font.png" - case extension == ".pdf": - return "/static/img/sprites/journal.png" - case extension == ".html" || extension == ".css" || extension == ".js": - return "/static/img/sprites/frame_web-0.png" - default: - return "/static/img/icons/Windows Icons - PNG/ole2.dll_14_DEFICON.png" - } -} - -func formatBytes(bytes int64) string { - units := []string{"B", "KB", "MB", "GB"} - size := float64(bytes) - unitIndex := 0 - - for size >= 1024 && unitIndex < len(units)-1 { - size /= 1024 - unitIndex++ - } - - if unitIndex == 0 { - return fmt.Sprintf("%d %s", bytes, units[unitIndex]) - } - - return fmt.Sprintf("%.1f %s", size, units[unitIndex]) -} diff --git a/static/css/upload.css b/static/css/upload.css index f606c7a..9ca2514 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -119,7 +119,7 @@ min-height: 0; margin-top: 8px; overflow-y: auto; - background: #ffffff; + color: #fff; border-top: 2px solid #808080; border-left: 2px solid #808080; border-right: 2px solid #ffffff; @@ -371,10 +371,6 @@ min-height: 126px; } - .upload-file-list { - min-height: 160px; - } - .upload-result { grid-template-columns: 64px minmax(0, 1fr) 68px; } diff --git a/static/js/box.js b/static/js/box.js index 816a00e..719ae89 100644 --- a/static/js/box.js +++ b/static/js/box.js @@ -48,7 +48,7 @@ async function refreshBoxStatus() { const boxID = boxPanel.dataset.boxId; const response = await fetch(`/box/${boxID}/status`); if (!response.ok) { - return false; + return true; } const result = await response.json(); @@ -63,10 +63,16 @@ async function refreshBoxStatus() { } if (boxPanel) { + const pollMS = Number.parseInt(boxPanel.dataset.pollMs, 10) || 5000; const timer = setInterval(async () => { - const hasLoadingFiles = await refreshBoxStatus(); - if (!hasLoadingFiles) { - clearInterval(timer); + try { + const hasLoadingFiles = await refreshBoxStatus(); + if (!hasLoadingFiles) { + clearInterval(timer); + } + } catch (error) { + // Keep polling through temporary network/server hiccups; otherwise + // an in-progress file can appear stuck forever after one bad poll. } - }, 1500); + }, pollMS); } diff --git a/templates/box.html b/templates/box.html index c7e0434..38fd685 100644 --- a/templates/box.html +++ b/templates/box.html @@ -40,7 +40,7 @@ /box/{{ .BoxID }} -
+
{{ if .Files }}
{{ range .Files }} diff --git a/templates/index.html b/templates/index.html index 8e138d4..fb44897 100644 --- a/templates/index.html +++ b/templates/index.html @@ -44,15 +44,15 @@ 0 files
-
-

No files selected

-
- + +
+

No files selected

+