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 }}
-
No files selected
-No files selected
+