diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go new file mode 100644 index 0000000..e42b47a --- /dev/null +++ b/lib/boxstore/store.go @@ -0,0 +1,399 @@ +package boxstore + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + + "warpbox/lib/helpers" + "warpbox/lib/models" +) + +const ( + UploadRoot = "data/uploads" + manifestFile = ".warpbox.json" +) + +var manifestMu sync.Mutex + +func NewBoxID() (string, error) { + return helpers.RandomHexID(16) +} + +func ValidBoxID(boxID string) bool { + return helpers.ValidLowerHexID(boxID, 32) +} + +func BoxPath(boxID string) string { + return filepath.Join(UploadRoot, boxID) +} + +func ManifestPath(boxID string) string { + return filepath.Join(BoxPath(boxID), manifestFile) +} + +func SafeBoxFilePath(boxID string, filename string) (string, bool) { + return helpers.SafeChildPath(BoxPath(boxID), filename) +} + +func ListFiles(boxID string) ([]models.BoxFile, error) { + if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 { + files := make([]models.BoxFile, 0, len(manifest.Files)) + for _, file := range manifest.Files { + files = append(files, DecorateFile(boxID, file)) + } + + return files, nil + } + + return listCompletedFilesFromDisk(boxID) +} + +func CreateManifest(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 := helpers.SafeFilename(request.Name) + if !ok { + return nil, fmt.Errorf("Invalid filename") + } + + filename = helpers.UniqueNameInBatch(filename, usedNames) + fileID, err := helpers.RandomHexID(8) + 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: models.FileStatusWait, + }) + } + + manifest := models.BoxManifest{Files: files} + if err := WriteManifest(boxID, manifest); err != nil { + return nil, err + } + + decoratedFiles := make([]models.BoxFile, 0, len(files)) + for _, file := range files { + decoratedFiles = append(decoratedFiles, DecorateFile(boxID, file)) + } + + return decoratedFiles, nil +} + +func MarkFileStatus(boxID string, fileID string, status string) (models.BoxFile, error) { + if status != models.FileStatusWait && status != models.FileStatusWork && status != models.FileStatusReady && status != models.FileStatusFailed { + return models.BoxFile{}, fmt.Errorf("Invalid file status") + } + + manifestMu.Lock() + defer manifestMu.Unlock() + + manifest, err := readManifestUnlocked(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 := writeManifestUnlocked(boxID, manifest); err != nil { + return models.BoxFile{}, err + } + + return DecorateFile(boxID, manifest.Files[index]), nil + } + + return models.BoxFile{}, fmt.Errorf("File not found") +} + +func ReadManifest(boxID string) (models.BoxManifest, error) { + manifestMu.Lock() + defer manifestMu.Unlock() + + return readManifestUnlocked(boxID) +} + +func WriteManifest(boxID string, manifest models.BoxManifest) error { + manifestMu.Lock() + defer manifestMu.Unlock() + + return writeManifestUnlocked(boxID, manifest) +} + +func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error { + source, err := os.Open(filepath.Join(BoxPath(boxID), filename)) + 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 SaveManifestUpload(boxID string, fileID string, file *multipart.FileHeader) (models.BoxFile, error) { + manifestMu.Lock() + defer manifestMu.Unlock() + + manifest, err := readManifestUnlocked(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 := saveMultipartFile(file, destination); err != nil { + manifest.Files[fileIndex].Status = models.FileStatusFailed + writeManifestUnlocked(boxID, manifest) + return models.BoxFile{}, fmt.Errorf("Could not save uploaded file") + } + + manifest.Files[fileIndex].Size = file.Size + manifest.Files[fileIndex].MimeType = helpers.MimeTypeForFile(destination, filename) + manifest.Files[fileIndex].Status = models.FileStatusReady + if err := writeManifestUnlocked(boxID, manifest); err != nil { + return models.BoxFile{}, err + } + + return DecorateFile(boxID, manifest.Files[fileIndex]), nil +} + +func SaveUpload(boxID string, file *multipart.FileHeader) (models.BoxFile, error) { + filename, ok := helpers.SafeFilename(file.Filename) + if !ok { + return models.BoxFile{}, fmt.Errorf("Invalid filename") + } + + boxPath := BoxPath(boxID) + if err := os.MkdirAll(boxPath, 0755); err != nil { + return models.BoxFile{}, fmt.Errorf("Could not prepare upload box") + } + + filename = helpers.UniqueFilename(boxPath, filename) + destination := filepath.Join(boxPath, filename) + if err := saveMultipartFile(file, destination); err != nil { + return models.BoxFile{}, fmt.Errorf("Could not save uploaded file") + } + + return DecorateFile(boxID, models.BoxFile{ + ID: filename, + Name: filename, + Size: file.Size, + MimeType: helpers.MimeTypeForFile(destination, filename), + Status: models.FileStatusReady, + }), nil +} + +func DecorateFile(boxID string, file models.BoxFile) models.BoxFile { + if file.MimeType == "" { + file.MimeType = helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), file.Name), file.Name) + } + + if file.SizeLabel == "" { + file.SizeLabel = helpers.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 == models.FileStatusReady + + switch file.Status { + case models.FileStatusReady: + file.StatusLabel = "Ready" + file.Title = "Download " + file.Name + case models.FileStatusFailed: + file.StatusLabel = "Failed" + file.Title = "Failed to upload" + case models.FileStatusWork: + file.StatusLabel = "Loading" + file.Title = "Loading" + default: + file.Status = models.FileStatusWait + file.StatusLabel = "Waiting" + file.Title = "Loading" + } + + return file +} + +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 reconcileManifest(boxID string) (models.BoxManifest, error) { + manifestMu.Lock() + defer manifestMu.Unlock() + + manifest, err := readManifestUnlocked(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 == models.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 = helpers.MimeTypeForFile(path, file.Name) + manifest.Files[index].Status = models.FileStatusReady + changed = true + } + + if changed { + if err := writeManifestUnlocked(boxID, manifest); err != nil { + return manifest, err + } + } + + return manifest, nil +} + +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() == manifestFile { + continue + } + + info, err := entry.Info() + if err != nil { + return nil, err + } + + name := entry.Name() + files = append(files, DecorateFile(boxID, models.BoxFile{ + ID: name, + Name: name, + Size: info.Size(), + MimeType: helpers.MimeTypeForFile(filepath.Join(BoxPath(boxID), name), name), + Status: models.FileStatusReady, + })) + } + + return files, nil +} + +func readManifestUnlocked(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 +} + +// Manifest writes are serialized because the browser can upload several files +// concurrently into the same box. Without this lock, status updates can race. +func writeManifestUnlocked(boxID string, manifest models.BoxManifest) error { + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + + return os.WriteFile(ManifestPath(boxID), data, 0644) +} + +func saveMultipartFile(file *multipart.FileHeader, destination string) error { + source, err := file.Open() + if err != nil { + return err + } + defer source.Close() + + target, err := os.Create(destination) + if err != nil { + return err + } + defer target.Close() + + _, err = io.Copy(target, source) + return err +} diff --git a/lib/helpers/env.go b/lib/helpers/env.go new file mode 100644 index 0000000..2b00a45 --- /dev/null +++ b/lib/helpers/env.go @@ -0,0 +1,20 @@ +package helpers + +import ( + "os" + "strconv" +) + +func EnvInt(name string, fallback int, minimum int) int { + rawValue := os.Getenv(name) + if rawValue == "" { + return fallback + } + + value, err := strconv.Atoi(rawValue) + if err != nil || value < minimum { + return fallback + } + + return value +} diff --git a/lib/helpers/format.go b/lib/helpers/format.go new file mode 100644 index 0000000..f9d428f --- /dev/null +++ b/lib/helpers/format.go @@ -0,0 +1,20 @@ +package helpers + +import "fmt" + +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/helpers/ids.go b/lib/helpers/ids.go new file mode 100644 index 0000000..4094a52 --- /dev/null +++ b/lib/helpers/ids.go @@ -0,0 +1,30 @@ +package helpers + +import ( + "crypto/rand" + "encoding/hex" + "strings" +) + +func RandomHexID(byteCount int) (string, error) { + bytes := make([]byte, byteCount) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + return hex.EncodeToString(bytes), nil +} + +func ValidLowerHexID(value string, length int) bool { + if len(value) != length { + return false + } + + for _, character := range value { + if !strings.ContainsRune("0123456789abcdef", character) { + return false + } + } + + return true +} diff --git a/lib/helpers/mime.go b/lib/helpers/mime.go new file mode 100644 index 0000000..1eba816 --- /dev/null +++ b/lib/helpers/mime.go @@ -0,0 +1,30 @@ +package helpers + +import ( + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" +) + +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]) +} diff --git a/lib/server/paths.go b/lib/helpers/paths.go similarity index 59% rename from lib/server/paths.go rename to lib/helpers/paths.go index 9ad4dde..a1b2d45 100644 --- a/lib/server/paths.go +++ b/lib/helpers/paths.go @@ -1,4 +1,4 @@ -package server +package helpers import ( "os" @@ -7,26 +7,18 @@ import ( "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) { +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 SafeChildPath(parent string, filename string) (string, bool) { + path := filepath.Join(parent, filename) + return path, strings.HasPrefix(path, parent+string(filepath.Separator)) } -func uniqueFilename(directory string, filename string) string { +func UniqueFilename(directory string, filename string) string { if _, err := os.Stat(filepath.Join(directory, filename)); os.IsNotExist(err) { return filename } @@ -41,7 +33,7 @@ func uniqueFilename(directory string, filename string) string { } } -func uniqueManifestFilename(filename string, usedNames map[string]int) string { +func UniqueNameInBatch(filename string, usedNames map[string]int) string { count := usedNames[filename] usedNames[filename] = count + 1 diff --git a/lib/models/models.go b/lib/models/models.go index 094f0bf..4cd7716 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -1,5 +1,12 @@ package models +const ( + FileStatusFailed = "failed" + FileStatusReady = "complete" + FileStatusWait = "pending" + FileStatusWork = "uploading" +) + type BoxFile struct { ID string `json:"id"` Name string `json:"name"` diff --git a/lib/server/config.go b/lib/server/config.go deleted file mode 100644 index 2ed05ba..0000000 --- a/lib/server/config.go +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 2ef6008..0000000 --- a/lib/server/constants.go +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 5977342..0000000 --- a/lib/server/files.go +++ /dev/null @@ -1,125 +0,0 @@ -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 index f4a0a04..42163fb 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -9,6 +9,8 @@ import ( "github.com/gin-gonic/gin" + "warpbox/lib/boxstore" + "warpbox/lib/helpers" "warpbox/lib/models" ) @@ -18,12 +20,12 @@ func handleIndex(ctx *gin.Context) { func handleShowBox(ctx *gin.Context) { boxID := ctx.Param("id") - if !validBoxID(boxID) { + if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") return } - files, err := listBoxFiles(boxID) + files, err := boxstore.ListFiles(boxID) if err != nil { ctx.String(http.StatusNotFound, "Box not found") return @@ -34,18 +36,18 @@ func handleShowBox(ctx *gin.Context) { "Files": files, "FileCount": len(files), "DownloadAll": "/box/" + boxID + "/download", - "PollMS": boxPollingIntervalMS(), + "PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000), }) } func handleBoxStatus(ctx *gin.Context) { boxID := ctx.Param("id") - if !validBoxID(boxID) { + if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } - files, err := listBoxFiles(boxID) + files, err := boxstore.ListFiles(boxID) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"}) return @@ -56,12 +58,12 @@ func handleBoxStatus(ctx *gin.Context) { func handleDownloadBox(ctx *gin.Context) { boxID := ctx.Param("id") - if !validBoxID(boxID) { + if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") return } - files, err := listBoxFiles(boxID) + files, err := boxstore.ListFiles(boxID) if err != nil { ctx.String(http.StatusNotFound, "Box not found") return @@ -78,7 +80,7 @@ func handleDownloadBox(ctx *gin.Context) { continue } - if err := addFileToZip(zipWriter, boxID, file.Name); err != nil { + if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil { ctx.Status(http.StatusInternalServerError) return } @@ -87,13 +89,13 @@ func handleDownloadBox(ctx *gin.Context) { func handleDownloadFile(ctx *gin.Context) { boxID := ctx.Param("id") - filename, ok := safeFilename(ctx.Param("filename")) - if !validBoxID(boxID) || !ok { + filename, ok := helpers.SafeFilename(ctx.Param("filename")) + if !boxstore.ValidBoxID(boxID) || !ok { ctx.String(http.StatusBadRequest, "Invalid file") return } - path, ok := safeBoxFilePath(boxID, filename) + path, ok := boxstore.SafeBoxFilePath(boxID, filename) if !ok { ctx.String(http.StatusBadRequest, "Invalid file") return @@ -108,13 +110,13 @@ func handleDownloadFile(ctx *gin.Context) { } func handleCreateBox(ctx *gin.Context) { - boxID, err := newBoxID() + boxID, err := boxstore.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 { + if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"}) return } @@ -125,7 +127,7 @@ func handleCreateBox(ctx *gin.Context) { return } - files, err := createBoxManifest(boxID, request.Files) + files, err := boxstore.CreateManifest(boxID, request.Files) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -137,21 +139,21 @@ func handleCreateBox(ctx *gin.Context) { func handleManifestFileUpload(ctx *gin.Context) { boxID := ctx.Param("id") fileID := ctx.Param("file_id") - if !validBoxID(boxID) { + if !boxstore.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) + boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) return } - savedFile, err := saveManifestUploadedFile(ctx, boxID, fileID, file) + savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file) if err != nil { - markManifestFileStatus(boxID, fileID, fileStatusFailed) + boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -162,7 +164,7 @@ func handleManifestFileUpload(ctx *gin.Context) { func handleFileStatusUpdate(ctx *gin.Context) { boxID := ctx.Param("id") fileID := ctx.Param("file_id") - if !validBoxID(boxID) { + if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } @@ -173,7 +175,7 @@ func handleFileStatusUpdate(ctx *gin.Context) { return } - file, err := markManifestFileStatus(boxID, fileID, request.Status) + file, err := boxstore.MarkFileStatus(boxID, fileID, request.Status) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -184,7 +186,7 @@ func handleFileStatusUpdate(ctx *gin.Context) { func handleDirectBoxUpload(ctx *gin.Context) { boxID := ctx.Param("id") - if !validBoxID(boxID) { + if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } @@ -195,7 +197,7 @@ func handleDirectBoxUpload(ctx *gin.Context) { return } - savedFile, err := saveUploadedFile(ctx, boxID, file) + savedFile, err := boxstore.SaveUpload(boxID, file) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -217,20 +219,20 @@ func handleLegacyUpload(ctx *gin.Context) { return } - boxID, err := newBoxID() + boxID, err := boxstore.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 { + if err := os.MkdirAll(boxstore.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)) + savedFiles := make([]models.BoxFile, 0, len(files)) for _, file := range files { - savedFile, err := saveUploadedFile(ctx, boxID, file) + savedFile, err := boxstore.SaveUpload(boxID, file) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/lib/server/ids.go b/lib/server/ids.go deleted file mode 100644 index e3bb594..0000000 --- a/lib/server/ids.go +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 468e7c4..0000000 --- a/lib/server/manifest.go +++ /dev/null @@ -1,177 +0,0 @@ -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/presentation.go b/lib/server/presentation.go deleted file mode 100644 index 3ec7cc5..0000000 --- a/lib/server/presentation.go +++ /dev/null @@ -1,111 +0,0 @@ -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/static/WarpBoxLogo.png b/static/WarpBoxLogo.png new file mode 100644 index 0000000..9eb5c37 Binary files /dev/null and b/static/WarpBoxLogo.png differ diff --git a/static/img/icons/crypto/LTC_pixel.png b/static/img/icons/crypto/LTC_pixel.png deleted file mode 100644 index 983d9b1..0000000 Binary files a/static/img/icons/crypto/LTC_pixel.png and /dev/null differ diff --git a/static/img/icons/crypto/XMR_pixel.png b/static/img/icons/crypto/XMR_pixel.png deleted file mode 100644 index a973b0e..0000000 Binary files a/static/img/icons/crypto/XMR_pixel.png and /dev/null differ