refactor(server): use boxstore helpers and file status consts

- Move box ID validation, file listing/pathing, manifest creation, and uploads to `boxstore`
- Use shared helpers for safe filenames and polling interval env parsing
- Add file status constants to `models` to avoid duplicated magic strings across handlersrefactor(server): use boxstore helpers and file status consts

- Move box ID validation, file listing/pathing, manifest creation, and uploads to `boxstore`
- Use shared helpers for safe filenames and polling interval env parsing
- Add file status constants to `models` to avoid duplicated magic strings across handlers
This commit is contained in:
2026-04-27 18:01:02 +03:00
parent cf90e08f98
commit 2f37958c31
17 changed files with 541 additions and 525 deletions

399
lib/boxstore/store.go Normal file
View File

@@ -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
}

20
lib/helpers/env.go Normal file
View File

@@ -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
}

20
lib/helpers/format.go Normal file
View File

@@ -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])
}

30
lib/helpers/ids.go Normal file
View File

@@ -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
}

30
lib/helpers/mime.go Normal file
View File

@@ -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])
}

View File

@@ -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

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -1,12 +0,0 @@
package server
const (
uploadRoot = "data/uploads"
boxManifestFile = ".warpbox.json"
boxPollInterval = 5000
fileStatusFailed = "failed"
fileStatusReady = "complete"
fileStatusWait = "pending"
fileStatusWork = "uploading"
)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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])
}

BIN
static/WarpBoxLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B