refactor(code): Cleaned-up the code base

This commit is contained in:
2026-04-30 11:05:56 +03:00
parent a729b641b2
commit f0b723e35d
71 changed files with 6848 additions and 5394 deletions

222
lib/boxstore/files.go Normal file
View File

@@ -0,0 +1,222 @@
package boxstore
import (
"fmt"
"io"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func ListFiles(boxID string) ([]models.BoxFile, error) {
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
return DecorateFiles(boxID, manifest.Files), nil
}
return listCompletedFilesFromDisk(boxID)
}
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
}
if IsExpired(manifest) {
return models.BoxFile{}, fmt.Errorf("Box expired")
}
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, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil {
manifest.Files[fileIndex].Status = models.FileStatusFailed
startRetentionIfTerminalUnlocked(&manifest)
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
startRetentionIfTerminalUnlocked(&manifest)
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, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid 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 == "" {
if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
}
}
if file.SizeLabel == "" {
file.SizeLabel = helpers.FormatBytes(file.Size)
}
file.IconPath = IconForMimeType(file.MimeType, file.Name)
if file.ThumbnailPath != nil {
file.ThumbnailURL = *file.ThumbnailPath
}
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 DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
decorated := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decorated = append(decorated, DecorateFile(boxID, file))
}
return decorated
}
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 || entry.Type()&os.ModeSymlink != 0 {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
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 saveMultipartFile(file *multipart.FileHeader, destination string) error {
source, err := file.Open()
if err != nil {
return err
}
defer source.Close()
target, tempPath, err := createTempSibling(destination)
if err != nil {
return err
}
committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
if _, err := io.Copy(target, source); err != nil {
return err
}
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, destination); err != nil {
return err
}
committed = true
return nil
}
func createTempSibling(destination string) (*os.File, string, error) {
directory := filepath.Dir(destination)
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, "", err
}
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
if err != nil {
return nil, "", err
}
return target, target.Name(), nil
}

33
lib/boxstore/icons.go Normal file
View File

@@ -0,0 +1,33 @@
package boxstore
import (
"path/filepath"
"strings"
)
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"
}
}

220
lib/boxstore/manifest.go Normal file
View File

@@ -0,0 +1,220 @@
package boxstore
import (
"encoding/json"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
var manifestMu sync.Mutex
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
retention := normalizeRetentionOption(request.RetentionKey)
usedNames := make(map[string]int, len(request.Files))
files := make([]models.BoxFile, 0, len(request.Files))
for _, fileRequest := range request.Files {
filename, ok := helpers.SafeFilename(fileRequest.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: fileRequest.Size,
MimeType: mimeType,
Status: models.FileStatusWait,
})
}
now := time.Now().UTC()
disableZip := false
if request.AllowZip != nil {
disableZip = !*request.AllowZip
}
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
if oneTimeDownload {
disableZip = false
}
manifest := models.BoxManifest{
Files: files,
CreatedAt: now,
RetentionKey: retention.Key,
RetentionLabel: retention.Label,
RetentionSecs: retention.Seconds,
DisableZip: disableZip,
OneTimeDownload: oneTimeDownload,
}
if password := strings.TrimSpace(request.Password); password != "" {
authToken, err := helpers.RandomHexID(16)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
manifest.PasswordHash = string(passwordHash)
manifest.PasswordHashAlg = "bcrypt"
manifest.AuthToken = authToken
}
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
startRetentionIfTerminalUnlocked(&manifest)
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 RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
return manifest, nil
}
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
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, ok := SafeBoxFilePath(boxID, file.Name)
if !ok || ensureRegularFile(path) != nil {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
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 {
startRetentionIfTerminalUnlocked(&manifest)
if err := writeManifestUnlocked(boxID, manifest); err != nil {
return manifest, err
}
}
return manifest, 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)
}

79
lib/boxstore/paths.go Normal file
View File

@@ -0,0 +1,79 @@
package boxstore
import (
"fmt"
"os"
"path/filepath"
"warpbox/lib/helpers"
)
const manifestFile = ".warpbox.json"
var uploadRoot = filepath.Join("data", "uploads")
func NewBoxID() (string, error) {
return helpers.RandomHexID(16)
}
func ValidBoxID(boxID string) bool {
return helpers.ValidLowerHexID(boxID, 32)
}
func SetUploadRoot(path string) {
if path == "" {
return
}
uploadRoot = filepath.Clean(path)
}
func UploadRoot() string {
return uploadRoot
}
func BoxPath(boxID string) string {
return filepath.Join(uploadRoot, boxID)
}
func safeBoxPath(boxID string) (string, bool) {
if !ValidBoxID(boxID) {
return "", false
}
return helpers.SafeChildPath(uploadRoot, boxID)
}
func ManifestPath(boxID string) string {
return filepath.Join(BoxPath(boxID), manifestFile)
}
func SafeBoxFilePath(boxID string, filename string) (string, bool) {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return "", false
}
return helpers.SafeChildPath(boxPath, filename)
}
func IsSafeRegularBoxFile(boxID string, filename string) bool {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return false
}
return ensureRegularFile(path) == nil
}
func DeleteBox(boxID string) error {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return fmt.Errorf("Invalid box id")
}
return os.RemoveAll(boxPath)
}
func ensureRegularFile(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("Invalid file")
}
return nil
}

74
lib/boxstore/retention.go Normal file
View File

@@ -0,0 +1,74 @@
package boxstore
import (
"time"
"warpbox/lib/models"
)
const OneTimeDownloadRetentionKey = "one-time"
var oneTimeDownloadExpiry int64
var retentionOptions = []models.RetentionOption{
{Key: "10s", Label: "10 seconds", Seconds: 10},
{Key: "10m", Label: "10 minutes", Seconds: 10 * 60},
{Key: "1h", Label: "1 hour", Seconds: 60 * 60},
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
{Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0},
}
func RetentionOptions() []models.RetentionOption {
options := make([]models.RetentionOption, len(retentionOptions))
copy(options, retentionOptions)
return options
}
func DefaultRetentionOption() models.RetentionOption {
return retentionOptions[0]
}
func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func OneTimeDownloadExpiry() int64 {
return oneTimeDownloadExpiry
}
func normalizeRetentionOption(key string) models.RetentionOption {
for _, option := range retentionOptions {
if option.Key == key {
return option
}
}
return DefaultRetentionOption()
}
func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
return
}
seconds := manifest.RetentionSecs
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
if seconds <= 0 {
return
}
for _, file := range manifest.Files {
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
return
}
}
// Retention starts after uploads settle so slow or very large uploads do
// not expire before users get a real chance to open the box.
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
}

51
lib/boxstore/security.go Normal file
View File

@@ -0,0 +1,51 @@
package boxstore
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/models"
)
func IsExpired(manifest models.BoxManifest) bool {
return !manifest.ExpiresAt.IsZero() && time.Now().UTC().After(manifest.ExpiresAt)
}
func IsPasswordProtected(manifest models.BoxManifest) bool {
return manifest.PasswordHash != "" && manifest.AuthToken != ""
}
func VerifyPassword(manifest models.BoxManifest, password string) bool {
if !IsPasswordProtected(manifest) {
return true
}
expected := manifest.PasswordHash
if manifest.PasswordHashAlg == "bcrypt" || strings.HasPrefix(expected, "$2") {
return bcrypt.CompareHashAndPassword([]byte(expected), []byte(password)) == nil
}
actual := legacyPasswordHash(manifest.PasswordSalt, password)
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func VerifyAuthToken(manifest models.BoxManifest, token string) bool {
if !IsPasswordProtected(manifest) {
return true
}
if token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(manifest.AuthToken), []byte(token)) == 1
}
func legacyPasswordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}

View File

@@ -1,759 +0,0 @@
package boxstore
import (
"archive/zip"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
const (
manifestFile = ".warpbox.json"
OneTimeDownloadRetentionKey = "one-time"
)
var (
uploadRoot = filepath.Join("data", "uploads")
oneTimeDownloadExpiry int64
manifestMu sync.Mutex
)
var retentionOptions = []models.RetentionOption{
{Key: "10s", Label: "10 seconds", Seconds: 10},
{Key: "10m", Label: "10 minutes", Seconds: 10 * 60},
{Key: "1h", Label: "1 hour", Seconds: 60 * 60},
{Key: "12h", Label: "12 hours", Seconds: 12 * 60 * 60},
{Key: "24h", Label: "24 hours", Seconds: 24 * 60 * 60},
{Key: "48h", Label: "48 hours", Seconds: 48 * 60 * 60},
{Key: OneTimeDownloadRetentionKey, Label: "One time download", Seconds: 0},
}
func NewBoxID() (string, error) {
return helpers.RandomHexID(16)
}
func ValidBoxID(boxID string) bool {
return helpers.ValidLowerHexID(boxID, 32)
}
func RetentionOptions() []models.RetentionOption {
options := make([]models.RetentionOption, len(retentionOptions))
copy(options, retentionOptions)
return options
}
func DefaultRetentionOption() models.RetentionOption {
return retentionOptions[0]
}
func SetUploadRoot(path string) {
if path == "" {
return
}
uploadRoot = filepath.Clean(path)
}
func SetOneTimeDownloadExpiry(seconds int64) {
oneTimeDownloadExpiry = seconds
}
func OneTimeDownloadExpiry() int64 {
return oneTimeDownloadExpiry
}
func UploadRoot() string {
return uploadRoot
}
func BoxPath(boxID string) string {
return filepath.Join(uploadRoot, boxID)
}
func safeBoxPath(boxID string) (string, bool) {
if !ValidBoxID(boxID) {
return "", false
}
return helpers.SafeChildPath(uploadRoot, boxID)
}
func ManifestPath(boxID string) string {
return filepath.Join(BoxPath(boxID), manifestFile)
}
func SafeBoxFilePath(boxID string, filename string) (string, bool) {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return "", false
}
return helpers.SafeChildPath(boxPath, filename)
}
func IsSafeRegularBoxFile(boxID string, filename string) bool {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return false
}
return ensureRegularFile(path) == nil
}
func DeleteBox(boxID string) error {
boxPath, ok := safeBoxPath(boxID)
if !ok {
return fmt.Errorf("Invalid box id")
}
return os.RemoveAll(boxPath)
}
func ListBoxSummaries() ([]models.BoxSummary, error) {
entries, err := os.ReadDir(uploadRoot)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
summaries := make([]models.BoxSummary, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
continue
}
summary, err := BoxSummary(entry.Name())
if err != nil {
continue
}
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i int, j int) bool {
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
})
return summaries, nil
}
func BoxSummary(boxID string) (models.BoxSummary, error) {
files, err := ListFiles(boxID)
if err != nil {
return models.BoxSummary{}, err
}
var manifest models.BoxManifest
hasManifest := false
if readManifest, err := ReadManifest(boxID); err == nil {
manifest = readManifest
hasManifest = true
}
totalSize := int64(0)
for _, file := range files {
totalSize += file.Size
}
summary := models.BoxSummary{
ID: boxID,
FileCount: len(files),
TotalSize: totalSize,
TotalSizeLabel: helpers.FormatBytes(totalSize),
}
if hasManifest {
summary.CreatedAt = manifest.CreatedAt
summary.ExpiresAt = manifest.ExpiresAt
summary.Expired = IsExpired(manifest)
summary.OneTimeDownload = manifest.OneTimeDownload
summary.PasswordProtected = IsPasswordProtected(manifest)
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
summary.CreatedAt = info.ModTime().UTC()
}
return summary, nil
}
func ListFiles(boxID string) ([]models.BoxFile, error) {
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
return DecorateFiles(boxID, manifest.Files), nil
}
return listCompletedFilesFromDisk(boxID)
}
func CreateManifest(boxID string, request models.CreateBoxRequest) ([]models.BoxFile, error) {
retention := normalizeRetentionOption(request.RetentionKey)
usedNames := make(map[string]int, len(request.Files))
files := make([]models.BoxFile, 0, len(request.Files))
for _, fileRequest := range request.Files {
filename, ok := helpers.SafeFilename(fileRequest.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: fileRequest.Size,
MimeType: mimeType,
Status: models.FileStatusWait,
})
}
now := time.Now().UTC()
disableZip := false
if request.AllowZip != nil {
disableZip = !*request.AllowZip
}
oneTimeDownload := retention.Key == OneTimeDownloadRetentionKey
if oneTimeDownload {
disableZip = false
}
manifest := models.BoxManifest{
Files: files,
CreatedAt: now,
RetentionKey: retention.Key,
RetentionLabel: retention.Label,
RetentionSecs: retention.Seconds,
DisableZip: disableZip,
OneTimeDownload: oneTimeDownload,
}
if password := strings.TrimSpace(request.Password); password != "" {
authToken, err := helpers.RandomHexID(16)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("Could not secure upload box")
}
manifest.PasswordHash = string(passwordHash)
manifest.PasswordHashAlg = "bcrypt"
manifest.AuthToken = authToken
}
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 IsExpired(manifest models.BoxManifest) bool {
return !manifest.ExpiresAt.IsZero() && time.Now().UTC().After(manifest.ExpiresAt)
}
func IsPasswordProtected(manifest models.BoxManifest) bool {
return manifest.PasswordHash != "" && manifest.AuthToken != ""
}
func VerifyPassword(manifest models.BoxManifest, password string) bool {
if !IsPasswordProtected(manifest) {
return true
}
expected := manifest.PasswordHash
if manifest.PasswordHashAlg == "bcrypt" || strings.HasPrefix(expected, "$2") {
return bcrypt.CompareHashAndPassword([]byte(expected), []byte(password)) == nil
}
actual := legacyPasswordHash(manifest.PasswordSalt, password)
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func VerifyAuthToken(manifest models.BoxManifest, token string) bool {
if !IsPasswordProtected(manifest) {
return true
}
if token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(manifest.AuthToken), []byte(token)) == 1
}
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
startRetentionIfTerminalUnlocked(&manifest)
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 RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
return manifest, nil
}
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return fmt.Errorf("Invalid file")
}
if err := ensureRegularFile(path); err != nil {
return err
}
zipName, ok := safeZipEntryName(filename)
if !ok {
return fmt.Errorf("Invalid zip entry")
}
source, err := os.Open(path)
if err != nil {
return err
}
defer source.Close()
destination, err := zipWriter.Create(zipName)
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
}
if IsExpired(manifest) {
return models.BoxFile{}, fmt.Errorf("Box expired")
}
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, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid filename")
}
if err := saveMultipartFile(file, destination); err != nil {
manifest.Files[fileIndex].Status = models.FileStatusFailed
startRetentionIfTerminalUnlocked(&manifest)
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
startRetentionIfTerminalUnlocked(&manifest)
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, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return models.BoxFile{}, fmt.Errorf("Invalid 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 == "" {
if path, ok := SafeBoxFilePath(boxID, file.Name); ok {
file.MimeType = helpers.MimeTypeForFile(path, file.Name)
}
}
if file.SizeLabel == "" {
file.SizeLabel = helpers.FormatBytes(file.Size)
}
file.IconPath = IconForMimeType(file.MimeType, file.Name)
if file.ThumbnailPath != nil {
file.ThumbnailURL = *file.ThumbnailPath
}
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 DecorateFiles(boxID string, files []models.BoxFile) []models.BoxFile {
decorated := make([]models.BoxFile, 0, len(files))
for _, file := range files {
decorated = append(decorated, DecorateFile(boxID, file))
}
return decorated
}
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, ok := SafeBoxFilePath(boxID, file.Name)
if !ok || ensureRegularFile(path) != nil {
continue
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
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 {
startRetentionIfTerminalUnlocked(&manifest)
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 || entry.Type()&os.ModeSymlink != 0 {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
continue
}
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
}
func normalizeRetentionOption(key string) models.RetentionOption {
for _, option := range retentionOptions {
if option.Key == key {
return option
}
}
return DefaultRetentionOption()
}
func startRetentionIfTerminalUnlocked(manifest *models.BoxManifest) {
if !manifest.ExpiresAt.IsZero() || len(manifest.Files) == 0 {
return
}
seconds := manifest.RetentionSecs
if manifest.OneTimeDownload {
seconds = oneTimeDownloadExpiry
} else if seconds <= 0 {
seconds = normalizeRetentionOption(manifest.RetentionKey).Seconds
}
if seconds <= 0 {
return
}
for _, file := range manifest.Files {
if file.Status != models.FileStatusReady && file.Status != models.FileStatusFailed {
return
}
}
// Retention starts after uploads settle so slow or very large uploads do
// not expire before users get a real chance to open the box.
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
}
func legacyPasswordHash(salt string, password string) string {
sum := sha256.Sum256([]byte(salt + ":" + password))
return hex.EncodeToString(sum[:])
}
// 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, tempPath, err := createTempSibling(destination)
if err != nil {
return err
}
committed := false
defer func() {
target.Close()
if !committed {
os.Remove(tempPath)
}
}()
if _, err := io.Copy(target, source); err != nil {
return err
}
if err := target.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, destination); err != nil {
return err
}
committed = true
return nil
}
func createTempSibling(destination string) (*os.File, string, error) {
directory := filepath.Dir(destination)
if err := os.MkdirAll(directory, 0755); err != nil {
return nil, "", err
}
target, err := os.CreateTemp(directory, ".warpbox-upload-*")
if err != nil {
return nil, "", err
}
return target, target.Name(), nil
}
func safeZipEntryName(filename string) (string, bool) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.IsAbs(filename) {
return "", false
}
cleaned := filepath.ToSlash(filepath.Clean(filename))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
return "", false
}
return cleaned, true
}
func ensureRegularFile(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("Invalid file")
}
return nil
}

74
lib/boxstore/summary.go Normal file
View File

@@ -0,0 +1,74 @@
package boxstore
import (
"os"
"sort"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func ListBoxSummaries() ([]models.BoxSummary, error) {
entries, err := os.ReadDir(uploadRoot)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
summaries := make([]models.BoxSummary, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
continue
}
summary, err := BoxSummary(entry.Name())
if err != nil {
continue
}
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i int, j int) bool {
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
})
return summaries, nil
}
func BoxSummary(boxID string) (models.BoxSummary, error) {
files, err := ListFiles(boxID)
if err != nil {
return models.BoxSummary{}, err
}
var manifest models.BoxManifest
hasManifest := false
if readManifest, err := ReadManifest(boxID); err == nil {
manifest = readManifest
hasManifest = true
}
totalSize := int64(0)
for _, file := range files {
totalSize += file.Size
}
summary := models.BoxSummary{
ID: boxID,
FileCount: len(files),
TotalSize: totalSize,
TotalSizeLabel: helpers.FormatBytes(totalSize),
}
if hasManifest {
summary.CreatedAt = manifest.CreatedAt
summary.ExpiresAt = manifest.ExpiresAt
summary.Expired = IsExpired(manifest)
summary.OneTimeDownload = manifest.OneTimeDownload
summary.PasswordProtected = IsPasswordProtected(manifest)
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
summary.CreatedAt = info.ModTime().UTC()
}
return summary, nil
}

50
lib/boxstore/zip.go Normal file
View File

@@ -0,0 +1,50 @@
package boxstore
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
path, ok := SafeBoxFilePath(boxID, filename)
if !ok {
return fmt.Errorf("Invalid file")
}
if err := ensureRegularFile(path); err != nil {
return err
}
zipName, ok := safeZipEntryName(filename)
if !ok {
return fmt.Errorf("Invalid zip entry")
}
source, err := os.Open(path)
if err != nil {
return err
}
defer source.Close()
destination, err := zipWriter.Create(zipName)
if err != nil {
return err
}
_, err = io.Copy(destination, source)
return err
}
func safeZipEntryName(filename string) (string, bool) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.IsAbs(filename) {
return "", false
}
cleaned := filepath.ToSlash(filepath.Clean(filename))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
return "", false
}
return cleaned, true
}

View File

@@ -1,578 +0,0 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
type Source string
const (
SourceDefault Source = "default"
SourceEnv Source = "environment"
SourceDB Source = "db override"
)
type AdminEnabledMode string
const (
AdminEnabledAuto AdminEnabledMode = "auto"
AdminEnabledTrue AdminEnabledMode = "true"
AdminEnabledFalse AdminEnabledMode = "false"
)
const (
SettingGuestUploadsEnabled = "guest_uploads_enabled"
SettingAPIEnabled = "api_enabled"
SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
)
type SettingType string
const (
SettingTypeBool SettingType = "bool"
SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text"
)
type SettingDefinition struct {
Key string
EnvName string
Label string
Type SettingType
Editable bool
HardLimit bool
Minimum int64
}
type SettingRow struct {
Definition SettingDefinition
Value string
Source Source
}
type Config struct {
DataDir string
UploadsDir string
DBDir string
AdminPassword string
AdminUsername string
AdminEmail string
AdminEnabled AdminEnabledMode
AdminCookieSecure bool
AllowAdminSettingsOverride bool
GuestUploadsEnabled bool
APIEnabled bool
ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool
OneTimeDownloadExpirySeconds int64
OneTimeDownloadRetryOnFailure bool
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
sources map[string]Source
values map[string]string
}
var Definitions = []SettingDefinition{
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
}
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
}
cfg.captureDefaults()
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
}
cfg.AdminEnabled = mode
}
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
return nil, err
}
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
return nil, err
}
envBools := []struct {
key string
name string
target *bool
}{
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
}
for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
return nil, err
}
}
envInt64s := []struct {
key string
name string
min int64
target *int64
}{
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
sizeEnvVars := []struct {
key string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err
}
}
envInts := []struct {
key string
name string
min int
target *int
}{
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
cfg.DataDir = filepath.Clean(cfg.DataDir)
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
cfg.DataDir = "data"
}
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
}
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
return cfg, nil
}
func (cfg *Config) EnsureDirectories() error {
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
}
return nil
}
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
if !cfg.AllowAdminSettingsOverride {
return nil
}
for key, value := range overrides {
if err := cfg.ApplyOverride(key, value); err != nil {
return err
}
}
return nil
}
func (cfg *Config) ApplyOverride(key string, value string) error {
def, ok := Definition(key)
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if !def.Editable || def.HardLimit {
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
}
switch def.Type {
case SettingTypeBool:
parsed, err := parseBool(value)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignBool(key, parsed, SourceDB)
case SettingTypeInt64:
parsed, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeInt:
parsed64, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
return nil
}
func (cfg *Config) SettingRows() []SettingRow {
rows := make([]SettingRow, 0, len(Definitions))
for _, def := range Definitions {
rows = append(rows, SettingRow{
Definition: def,
Value: cfg.values[def.Key],
Source: cfg.sourceFor(def.Key),
})
}
return rows
}
func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:
return false
case AdminEnabledTrue:
return hasAdminUser
default:
return hasAdminUser
}
}
func Definition(key string) (SettingDefinition, bool) {
for _, def := range Definitions {
if def.Key == key {
return def, true
}
}
return SettingDefinition{}, false
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {
if def.Editable && !def.HardLimit {
defs = append(defs, def)
}
}
return defs
}
func (cfg *Config) captureDefaults() {
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
}
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
raw := os.Getenv(name)
if raw == "" {
return nil
}
*target = raw
if key != "" {
cfg.setValue(key, raw, SourceEnv)
}
return nil
}
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseBool(raw)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, formatBool(parsed), SourceEnv)
}
return nil
}
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt64(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
}
return nil
}
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min)
if err != nil {
return fmt.Errorf("%s: %w", bytesName, err)
}
*target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
return nil
}
rawMB := strings.TrimSpace(os.Getenv(mbName))
if rawMB == "" {
return nil
}
parsedMB, err := parseInt64(rawMB, min)
if err != nil {
return fmt.Errorf("%s: %w", mbName, err)
}
if parsedMB > math.MaxInt64/(1024*1024) {
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
*target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
return nil
}
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
}
return nil
}
func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key {
case SettingGuestUploadsEnabled:
cfg.GuestUploadsEnabled = value
case SettingAPIEnabled:
cfg.APIEnabled = value
case SettingZipDownloadsEnabled:
cfg.ZipDownloadsEnabled = value
case SettingOneTimeDownloadsEnabled:
cfg.OneTimeDownloadsEnabled = value
case SettingRenewOnAccessEnabled:
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
func (cfg *Config) assignInt64(key string, value int64, source Source) {
switch key {
case SettingDefaultGuestExpirySecs:
cfg.DefaultGuestExpirySeconds = value
case SettingMaxGuestExpirySecs:
cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
cfg.setValue(key, strconv.FormatInt(value, 10), source)
}
func (cfg *Config) assignInt(key string, value int, source Source) {
switch key {
case SettingBoxPollIntervalMS:
cfg.BoxPollIntervalMS = value
case SettingThumbnailBatchSize:
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return
}
cfg.values[key] = value
cfg.sources[key] = source
}
func (cfg *Config) sourceFor(key string) Source {
source, ok := cfg.sources[key]
if !ok {
return SourceDefault
}
return source
}
func parseBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "t", "true", "y", "yes", "on":
return true, nil
case "0", "f", "false", "n", "no", "off":
return false, nil
default:
return false, fmt.Errorf("must be a boolean")
}
}
func parseInt64(value string, min int64) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %d", min)
}
return parsed, nil
}
func parseInt(value string, min int) (int, error) {
parsed64, err := parseInt64(value, int64(min))
if err != nil {
return 0, err
}
if parsed64 > int64(^uint(0)>>1) {
return 0, fmt.Errorf("is too large")
}
return int(parsed64), nil
}
func formatBool(value bool) string {
if value {
return "true"
}
return "false"
}

69
lib/config/definitions.go Normal file
View File

@@ -0,0 +1,69 @@
package config
var Definitions = []SettingDefinition{
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingOneTimeDownloadRetryFail, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", Label: "One-time download retry on failure", Type: SettingTypeBool, Editable: false},
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
}
func (cfg *Config) SettingRows() []SettingRow {
rows := make([]SettingRow, 0, len(Definitions))
for _, def := range Definitions {
rows = append(rows, SettingRow{
Definition: def,
Value: cfg.values[def.Key],
Source: cfg.sourceFor(def.Key),
})
}
return rows
}
func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:
return false
case AdminEnabledTrue:
return hasAdminUser
default:
return hasAdminUser
}
}
func Definition(key string) (SettingDefinition, bool) {
for _, def := range Definitions {
if def.Key == key {
return def, true
}
}
return SettingDefinition{}, false
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {
if def.Editable && !def.HardLimit {
defs = append(defs, def)
}
}
return defs
}

262
lib/config/load.go Normal file
View File

@@ -0,0 +1,262 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
}
// Config precedence: defaults -> env -> overrides.
// Overrides are applied after Load by the server once the metadata store opens.
cfg.captureDefaults()
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
return nil, err
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
}
cfg.AdminEnabled = mode
}
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
return nil, err
}
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
return nil, err
}
envBools := []struct {
key string
name string
target *bool
}{
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
}
for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
return nil, err
}
}
envInt64s := []struct {
key string
name string
min int64
target *int64
}{
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
sizeEnvVars := []struct {
key string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err
}
}
envInts := []struct {
key string
name string
min int
target *int
}{
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
return nil, err
}
}
cfg.DataDir = filepath.Clean(cfg.DataDir)
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
cfg.DataDir = "data"
}
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
}
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
return cfg, nil
}
func (cfg *Config) EnsureDirectories() error {
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
}
return nil
}
func (cfg *Config) captureDefaults() {
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault)
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
}
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
raw := os.Getenv(name)
if raw == "" {
return nil
}
*target = raw
if key != "" {
cfg.setValue(key, raw, SourceEnv)
}
return nil
}
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseBool(raw)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, formatBool(parsed), SourceEnv)
}
return nil
}
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt64(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
}
return nil
}
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min)
if err != nil {
return fmt.Errorf("%s: %w", bytesName, err)
}
*target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
return nil
}
rawMB := strings.TrimSpace(os.Getenv(mbName))
if rawMB == "" {
return nil
}
parsedMB, err := parseInt64(rawMB, min)
if err != nil {
return fmt.Errorf("%s: %w", mbName, err)
}
if parsedMB > math.MaxInt64/(1024*1024) {
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
*target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
return nil
}
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
raw := strings.TrimSpace(os.Getenv(name))
if raw == "" {
return nil
}
parsed, err := parseInt(raw, min)
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
*target = parsed
if key != "" {
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
}
return nil
}

100
lib/config/models.go Normal file
View File

@@ -0,0 +1,100 @@
package config
type Source string
const (
SourceDefault Source = "default"
SourceEnv Source = "environment"
SourceDB Source = "db override"
)
type AdminEnabledMode string
const (
AdminEnabledAuto AdminEnabledMode = "auto"
AdminEnabledTrue AdminEnabledMode = "true"
AdminEnabledFalse AdminEnabledMode = "false"
)
const (
SettingGuestUploadsEnabled = "guest_uploads_enabled"
SettingAPIEnabled = "api_enabled"
SettingZipDownloadsEnabled = "zip_downloads_enabled"
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds"
SettingOneTimeDownloadRetryFail = "one_time_download_retry_on_failure"
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
)
type SettingType string
const (
SettingTypeBool SettingType = "bool"
SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text"
)
type SettingDefinition struct {
Key string
EnvName string
Label string
Type SettingType
Editable bool
HardLimit bool
Minimum int64
}
type SettingRow struct {
Definition SettingDefinition
Value string
Source Source
}
type Config struct {
DataDir string
UploadsDir string
DBDir string
AdminPassword string
AdminUsername string
AdminEmail string
AdminEnabled AdminEnabledMode
AdminCookieSecure bool
AllowAdminSettingsOverride bool
GuestUploadsEnabled bool
APIEnabled bool
ZipDownloadsEnabled bool
OneTimeDownloadsEnabled bool
OneTimeDownloadExpirySeconds int64
OneTimeDownloadRetryOnFailure bool
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
sources map[string]Source
values map[string]string
}

115
lib/config/overrides.go Normal file
View File

@@ -0,0 +1,115 @@
package config
import (
"fmt"
"strconv"
)
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
if !cfg.AllowAdminSettingsOverride {
return nil
}
for key, value := range overrides {
if err := cfg.ApplyOverride(key, value); err != nil {
return err
}
}
return nil
}
func (cfg *Config) ApplyOverride(key string, value string) error {
def, ok := Definition(key)
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if !def.Editable || def.HardLimit {
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
}
switch def.Type {
case SettingTypeBool:
parsed, err := parseBool(value)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignBool(key, parsed, SourceDB)
case SettingTypeInt64:
parsed, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeInt:
parsed64, err := parseInt64(value, def.Minimum)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
return nil
}
func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key {
case SettingGuestUploadsEnabled:
cfg.GuestUploadsEnabled = value
case SettingAPIEnabled:
cfg.APIEnabled = value
case SettingZipDownloadsEnabled:
cfg.ZipDownloadsEnabled = value
case SettingOneTimeDownloadsEnabled:
cfg.OneTimeDownloadsEnabled = value
case SettingRenewOnAccessEnabled:
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
func (cfg *Config) assignInt64(key string, value int64, source Source) {
switch key {
case SettingDefaultGuestExpirySecs:
cfg.DefaultGuestExpirySeconds = value
case SettingMaxGuestExpirySecs:
cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
cfg.setValue(key, strconv.FormatInt(value, 10), source)
}
func (cfg *Config) assignInt(key string, value int, source Source) {
switch key {
case SettingBoxPollIntervalMS:
cfg.BoxPollIntervalMS = value
case SettingThumbnailBatchSize:
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return
}
cfg.values[key] = value
cfg.sources[key] = source
}
func (cfg *Config) sourceFor(key string) Source {
source, ok := cfg.sources[key]
if !ok {
return SourceDefault
}
return source
}

47
lib/config/parse.go Normal file
View File

@@ -0,0 +1,47 @@
package config
import (
"fmt"
"strconv"
"strings"
)
func parseBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "t", "true", "y", "yes", "on":
return true, nil
case "0", "f", "false", "n", "no", "off":
return false, nil
default:
return false, fmt.Errorf("must be a boolean")
}
}
func parseInt64(value string, min int64) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("must be an integer")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %d", min)
}
return parsed, nil
}
func parseInt(value string, min int) (int, error) {
parsed64, err := parseInt64(value, int64(min))
if err != nil {
return 0, err
}
if parsed64 > int64(^uint(0)>>1) {
return 0, fmt.Errorf("is too large")
}
return int(parsed64), nil
}
func formatBool(value bool) string {
if value {
return "true"
}
return "false"
}

View File

@@ -1,608 +0,0 @@
package server
import (
"crypto/subtle"
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
const adminSessionCookie = "warpbox_admin_session"
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
}
type adminTagRow struct {
ID string
Name string
Description string
Protected bool
AdminAccess bool
UploadAllowed bool
ZipDownloadAllowed bool
OneTimeDownloadAllowed bool
RenewableAllowed bool
MaxFileSizeBytes string
MaxBoxSizeBytes string
AllowedExpirySeconds string
}
type adminBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Expired bool
OneTimeDownload bool
PasswordProtected bool
}
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin)
admin.POST("/login", app.handleAdminLoginPost)
protected := admin.Group("")
protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)
protected.GET("/tags", app.handleAdminTags)
protected.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)
protected.POST("/settings", app.handleAdminSettingsPost)
}
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
return
}
app.renderAdminLogin(ctx, "")
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAdminLogin(ctx, "The username or password was not accepted.")
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
if !perms.AdminAccess {
app.renderAdminLogin(ctx, "This user does not have administrator access.")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
})
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
return
}
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list boxes")
return
}
rows := make([]adminBoxRow, 0, len(summaries))
totalSize := int64(0)
expiredCount := 0
for _, summary := range summaries {
totalSize += summary.TotalSize
if summary.Expired {
expiredCount++
}
rows = append(rows, adminBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Expired: summary.Expired,
OneTimeDownload: summary.OneTimeDownload,
PasswordProtected: summary.PasswordProtected,
})
}
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"Boxes": rows,
"TotalBoxes": len(rows),
"TotalStorage": helpers.FormatBytes(totalSize),
"ExpiredBoxes": expiredCount,
})
}
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminUsers(ctx, "")
}
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
if ctx.PostForm("action") == "toggle_disabled" {
userID := strings.TrimSpace(ctx.PostForm("user_id"))
user, ok, err := app.store.GetUser(userID)
if err != nil || !ok {
app.renderAdminUsers(ctx, "User not found.")
return
}
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
return
}
}
user.Disabled = !user.Disabled
if err := app.store.UpdateUser(user); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
return
}
username := ctx.PostForm("username")
email := ctx.PostForm("email")
password := ctx.PostForm("password")
tagIDs := ctx.PostFormArray("tag_ids")
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
}
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
users, err := app.store.ListUsers()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
tagNames := make(map[string]string, len(tags))
for _, tag := range tags {
tagNames[tag.ID] = tag.Name
}
sort.Slice(users, func(i int, j int) bool {
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
})
currentID := ""
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok {
currentID = currentUser.ID
}
}
rows := make([]adminUserRow, 0, len(users))
for _, user := range users {
names := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if name := tagNames[tagID]; name != "" {
names = append(names, name)
}
}
rows = append(rows, adminUserRow{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Tags: strings.Join(names, ", "),
CreatedAt: formatAdminTime(user.CreatedAt),
Disabled: user.Disabled,
IsCurrent: user.ID == currentID,
})
}
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows,
"Tags": tags,
"Error": errorMessage,
})
}
func (app *App) handleAdminTags(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminTags(ctx, "")
}
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
perms, err := parseTagPermissions(ctx)
if err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
tag := metastore.Tag{
Name: ctx.PostForm("name"),
Description: ctx.PostForm("description"),
Permissions: perms,
}
if err := app.store.CreateTag(&tag); err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
}
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
sort.Slice(tags, func(i int, j int) bool {
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
})
rows := make([]adminTagRow, 0, len(tags))
for _, tag := range tags {
rows = append(rows, adminTagRow{
ID: tag.ID,
Name: tag.Name,
Description: tag.Description,
Protected: tag.Protected,
AdminAccess: tag.Permissions.AdminAccess,
UploadAllowed: tag.Permissions.UploadAllowed,
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
RenewableAllowed: tag.Permissions.RenewableAllowed,
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
})
}
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows,
"Error": errorMessage,
})
}
func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
app.renderAdminSettings(ctx, "")
}
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
})
}
func (app *App) requireAdminSession(ctx *gin.Context) {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil || !perms.AdminAccess {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
perms, err := app.permissionsForUser(user)
return err == nil && perms.AdminAccess
}
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
tags, err := app.store.TagsByID(user.TagIDs)
if err != nil {
return metastore.EffectivePermissions{}, err
}
return metastore.ResolveUserPermissions(app.config, user, tags), nil
}
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
value, ok := ctx.Get("adminPerms")
if !ok {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok || !allowed(perms) {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
return true
}
func (app *App) currentAdminUsername(ctx *gin.Context) string {
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user.Username
}
}
return ""
}
func (app *App) currentCSRFToken(ctx *gin.Context) string {
if value, ok := ctx.Get("adminCSRFToken"); ok {
if token, ok := value.(string); ok {
return token
}
}
return ""
}
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func noStoreAdminHeaders(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.Header("Pragma", "no-cache")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Next()
}
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
switch ctx.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
token := ctx.PostForm("csrf_token")
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}
func subtleConstantTimeEqual(a string, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
}
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
}
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
if err != nil {
return metastore.TagPermissions{}, err
}
return metastore.TagPermissions{
UploadAllowed: checkbox(ctx, "upload_allowed"),
AllowedExpirySeconds: expirySeconds,
MaxFileSizeBytes: maxFileSize,
MaxBoxSizeBytes: maxBoxSize,
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
AdminAccess: checkbox(ctx, "admin_access"),
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
}, nil
}
func checkbox(ctx *gin.Context, name string) bool {
return ctx.PostForm(name) == "true"
}
func parseOptionalInt64(raw string) (*int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, errors.New("must be an integer")
}
if value < 0 {
return nil, errors.New("must be at least 0")
}
return &value, nil
}
func parseCSVInt64(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
}
if value < 0 {
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
}
values = append(values, value)
}
return values, nil
}
func optionalInt64Label(value *int64) string {
if value == nil {
return "-"
}
return strconv.FormatInt(*value, 10)
}
func joinInt64s(values []int64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ", ")
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Local().Format("2006-01-02 15:04:05")
}

192
lib/server/admin_auth.go Normal file
View File

@@ -0,0 +1,192 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const adminSessionCookie = "warpbox_admin_session"
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
return
}
app.renderAdminLogin(ctx, "")
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAdminLogin(ctx, "The username or password was not accepted.")
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
if !perms.AdminAccess {
app.renderAdminLogin(ctx, "This user does not have administrator access.")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) requireAdminSession(ctx *gin.Context) {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil || !perms.AdminAccess {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
perms, err := app.permissionsForUser(user)
return err == nil && perms.AdminAccess
}
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
tags, err := app.store.TagsByID(user.TagIDs)
if err != nil {
return metastore.EffectivePermissions{}, err
}
return metastore.ResolveUserPermissions(app.config, user, tags), nil
}
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
value, ok := ctx.Get("adminPerms")
if !ok {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok || !allowed(perms) {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
return true
}
func (app *App) currentAdminUsername(ctx *gin.Context) string {
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user.Username
}
}
return ""
}
func (app *App) currentCSRFToken(ctx *gin.Context) string {
if value, ok := ctx.Get("adminCSRFToken"); ok {
if token, ok := value.(string); ok {
return token
}
}
return ""
}
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func noStoreAdminHeaders(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.Header("Pragma", "no-cache")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Next()
}
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
switch ctx.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
token := ctx.PostForm("csrf_token")
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}
func subtleConstantTimeEqual(a string, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

63
lib/server/admin_boxes.go Normal file
View File

@@ -0,0 +1,63 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
type adminBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Expired bool
OneTimeDownload bool
PasswordProtected bool
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
return
}
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list boxes")
return
}
rows := make([]adminBoxRow, 0, len(summaries))
totalSize := int64(0)
expiredCount := 0
for _, summary := range summaries {
totalSize += summary.TotalSize
if summary.Expired {
expiredCount++
}
rows = append(rows, adminBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Expired: summary.Expired,
OneTimeDownload: summary.OneTimeDownload,
PasswordProtected: summary.PasswordProtected,
})
}
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
"AdminSection": "boxes",
"CurrentUser": app.currentAdminUsername(ctx),
"Boxes": rows,
"TotalBoxes": len(rows),
"TotalStorage": helpers.FormatBytes(totalSize),
"ExpiredBoxes": expiredCount,
})
}

View File

@@ -0,0 +1,14 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
})
}

View File

@@ -0,0 +1,73 @@
package server
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
func parseOptionalInt64(raw string) (*int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, errors.New("must be an integer")
}
if value < 0 {
return nil, errors.New("must be at least 0")
}
return &value, nil
}
func parseCSVInt64(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
}
if value < 0 {
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
}
values = append(values, value)
}
return values, nil
}
func optionalInt64Label(value *int64) string {
if value == nil {
return "-"
}
return strconv.FormatInt(*value, 10)
}
func joinInt64s(values []int64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ", ")
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Local().Format("2006-01-02 15:04:05")
}

View File

@@ -0,0 +1,23 @@
package server
import "github.com/gin-gonic/gin"
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin)
admin.POST("/login", app.handleAdminLoginPost)
protected := admin.Group("")
protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)
protected.GET("/tags", app.handleAdminTags)
protected.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)
protected.POST("/settings", app.handleAdminSettingsPost)
}

View File

@@ -0,0 +1,58 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
app.renderAdminSettings(ctx, "")
}
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"AdminSection": "settings",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
})
}

122
lib/server/admin_tags.go Normal file
View File

@@ -0,0 +1,122 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminTagRow struct {
ID string
Name string
Description string
Protected bool
AdminAccess bool
UploadAllowed bool
ZipDownloadAllowed bool
OneTimeDownloadAllowed bool
RenewableAllowed bool
MaxFileSizeBytes string
MaxBoxSizeBytes string
AllowedExpirySeconds string
}
func (app *App) handleAdminTags(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminTags(ctx, "")
}
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
perms, err := parseTagPermissions(ctx)
if err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
tag := metastore.Tag{
Name: ctx.PostForm("name"),
Description: ctx.PostForm("description"),
Permissions: perms,
}
if err := app.store.CreateTag(&tag); err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
}
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
sort.Slice(tags, func(i int, j int) bool {
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
})
rows := make([]adminTagRow, 0, len(tags))
for _, tag := range tags {
rows = append(rows, adminTagRow{
ID: tag.ID,
Name: tag.Name,
Description: tag.Description,
Protected: tag.Protected,
AdminAccess: tag.Permissions.AdminAccess,
UploadAllowed: tag.Permissions.UploadAllowed,
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
RenewableAllowed: tag.Permissions.RenewableAllowed,
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
})
}
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"AdminSection": "tags",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows,
"Error": errorMessage,
})
}
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
}
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
}
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
if err != nil {
return metastore.TagPermissions{}, err
}
return metastore.TagPermissions{
UploadAllowed: checkbox(ctx, "upload_allowed"),
AllowedExpirySeconds: expirySeconds,
MaxFileSizeBytes: maxFileSize,
MaxBoxSizeBytes: maxBoxSize,
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
AdminAccess: checkbox(ctx, "admin_access"),
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
}, nil
}
func checkbox(ctx *gin.Context, name string) bool {
return ctx.PostForm(name) == "true"
}

121
lib/server/admin_users.go Normal file
View File

@@ -0,0 +1,121 @@
package server
import (
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
}
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminUsers(ctx, "")
}
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
if ctx.PostForm("action") == "toggle_disabled" {
userID := strings.TrimSpace(ctx.PostForm("user_id"))
user, ok, err := app.store.GetUser(userID)
if err != nil || !ok {
app.renderAdminUsers(ctx, "User not found.")
return
}
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
return
}
}
user.Disabled = !user.Disabled
if err := app.store.UpdateUser(user); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
return
}
username := ctx.PostForm("username")
email := ctx.PostForm("email")
password := ctx.PostForm("password")
tagIDs := ctx.PostFormArray("tag_ids")
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
}
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
users, err := app.store.ListUsers()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
tagNames := make(map[string]string, len(tags))
for _, tag := range tags {
tagNames[tag.ID] = tag.Name
}
sort.Slice(users, func(i int, j int) bool {
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
})
currentID := ""
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok {
currentID = currentUser.ID
}
}
rows := make([]adminUserRow, 0, len(users))
for _, user := range users {
names := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if name := tagNames[tagID]; name != "" {
names = append(names, name)
}
}
rows = append(rows, adminUserRow{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Tags: strings.Join(names, ", "),
CreatedAt: formatAdminTime(user.CreatedAt),
Disabled: user.Disabled,
IsCurrent: user.ID == currentID,
})
}
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"AdminSection": "users",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows,
"Tags": tags,
"Error": errorMessage,
})
}

135
lib/server/box_auth.go Normal file
View File

@@ -0,0 +1,135 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
const boxAuthCookiePrefix = "warpbox_box_"
func handleBoxLogin(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
return
}
renderBoxLogin(ctx, boxID, "")
}
func handleBoxLoginPost(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
renderBoxLogin(ctx, boxID, "The password was not accepted.")
return
}
maxAge := 24 * 60 * 60
if !manifest.ExpiresAt.IsZero() {
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
if seconds > 0 {
maxAge = seconds
}
}
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
}
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return models.BoxManifest{}, false, true
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
if wantsHTML {
ctx.String(http.StatusGone, "Box expired")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
}
return manifest, true, false
}
if manifest.OneTimeDownload && manifest.Consumed {
if wantsHTML {
ctx.String(http.StatusGone, "Box already consumed")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
}
return manifest, true, false
}
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
if wantsHTML {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
} else {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
}
return manifest, true, false
}
if app.config.RenewOnAccessEnabled {
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
manifest = renewed
}
}
return manifest, true, true
}
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
token, err := ctx.Cookie(boxAuthCookieName(boxID))
return err == nil && boxstore.VerifyAuthToken(manifest, token)
}
func boxAuthCookieName(boxID string) string {
return boxAuthCookiePrefix + boxID
}
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
"BoxID": boxID,
"BoxUser": "WarpBox\\" + boxID,
"ErrorMessage": errorMessage,
})
}

281
lib/server/downloads.go Normal file
View File

@@ -0,0 +1,281 @@
package server
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"sync"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
var oneTimeDownloadLocks sync.Map
func (app *App) handleDownloadBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
if !app.config.ZipDownloadsEnabled {
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if hasManifest && manifest.OneTimeDownload {
app.handleOneTimeDownloadBox(ctx, boxID)
return
}
if hasManifest && manifest.DisableZip {
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
return
}
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
lock := oneTimeDownloadLock(boxID)
lock.Lock()
defer lock.Unlock()
defer oneTimeDownloadLocks.Delete(boxID)
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
ctx.String(http.StatusGone, "Box already consumed")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !allFilesComplete(files) {
ctx.String(http.StatusConflict, "Box is not ready yet")
return
}
if app.config.OneTimeDownloadRetryOnFailure {
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
boxstore.DeleteBox(boxID)
return
}
boxstore.DeleteBox(boxID)
}
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
writeBoxZipHeaders(ctx, boxID)
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
ctx.Status(http.StatusInternalServerError)
return false
}
return true
}
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
return
}
tempPath := tempZip.Name()
defer os.Remove(tempPath)
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
return
}
if _, err := tempZip.Seek(0, 0); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
return
}
writeBoxZipHeaders(ctx, boxID)
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
tempZip.Close()
return
}
if err := tempZip.Close(); err != nil {
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
return
}
boxstore.DeleteBox(boxID)
}
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
ctx.Header("Content-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
}
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
zipWriter := zip.NewWriter(destination)
for _, file := range files {
if !file.IsComplete {
continue
}
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
return err
}
}
if err := zipWriter.Close(); err != nil {
return err
}
return nil
}
func oneTimeDownloadLock(boxID string) *sync.Mutex {
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func allFilesComplete(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if !file.IsComplete {
return false
}
}
return true
}
func manifestFilesReady(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if file.Status != models.FileStatusReady {
return false
}
}
return true
}
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
stripped := make([]models.BoxFile, 0, len(files))
for _, file := range files {
file.ThumbnailPath = nil
file.ThumbnailURL = ""
if file.ThumbnailStatus == "" {
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
}
stripped = append(stripped, file)
}
return stripped
}
func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
if !boxstore.ValidBoxID(boxID) || !ok {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
return
}
path, ok := boxstore.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
}
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
return
}
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "Thumbnail not found")
return
}
ctx.Header("Content-Type", "image/jpeg")
ctx.File(path)
}

View File

@@ -1,904 +0,0 @@
package server
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
const boxAuthCookiePrefix = "warpbox_box_"
var oneTimeDownloadLocks sync.Map
func formatBrowserTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": app.retentionOptions(),
"DefaultRetention": app.defaultRetentionOption().Key,
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
})
}
func (app *App) handleShowBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
downloadAll := "/box/" + boxID + "/download"
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
downloadAll = ""
}
ctx.HTML(http.StatusOK, "box.html", gin.H{
"BoxID": boxID,
"Files": files,
"FileCount": len(files),
"DownloadAll": downloadAll,
"ZipOnly": hasManifest && manifest.OneTimeDownload,
"PollMS": app.config.BoxPollIntervalMS,
"RetentionLabel": manifest.RetentionLabel,
"ExpiresAt": manifest.ExpiresAt,
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
})
}
func handleBoxLogin(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.IsPasswordProtected(manifest) || isBoxAuthorized(ctx, boxID, manifest) {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
return
}
renderBoxLogin(ctx, boxID, "")
}
func handleBoxLoginPost(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
ctx.String(http.StatusGone, "Box expired")
return
}
if !boxstore.VerifyPassword(manifest, ctx.PostForm("password")) {
renderBoxLogin(ctx, boxID, "The password was not accepted.")
return
}
maxAge := 24 * 60 * 60
if !manifest.ExpiresAt.IsZero() {
seconds := int(time.Until(manifest.ExpiresAt).Seconds())
if seconds > 0 {
maxAge = seconds
}
}
ctx.SetCookie(boxAuthCookieName(boxID), manifest.AuthToken, maxAge, "/box/"+boxID, "", false, true)
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
}
func (app *App) handleBoxStatus(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
if !ok {
return
}
var files []models.BoxFile
if hasManifest && manifestFilesReady(manifest.Files) {
files = boxstore.DecorateFiles(boxID, manifest.Files)
} else {
var err error
files, err = boxstore.ListFiles(boxID)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
return
}
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
}
func (app *App) handleDownloadBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
if !app.config.ZipDownloadsEnabled {
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if hasManifest && manifest.OneTimeDownload {
app.handleOneTimeDownloadBox(ctx, boxID)
return
}
if hasManifest && manifest.DisableZip {
ctx.String(http.StatusForbidden, "Zip download disabled for this box")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
return
}
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleOneTimeDownloadBox(ctx *gin.Context, boxID string) {
lock := oneTimeDownloadLock(boxID)
lock.Lock()
defer lock.Unlock()
defer oneTimeDownloadLocks.Delete(boxID)
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
if !hasManifest || !manifest.OneTimeDownload || manifest.Consumed {
ctx.String(http.StatusGone, "Box already consumed")
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if !allFilesComplete(files) {
ctx.String(http.StatusConflict, "Box is not ready yet")
return
}
if app.config.OneTimeDownloadRetryOnFailure {
app.handleRetryableOneTimeZip(ctx, boxID, manifest, files)
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
ctx.String(http.StatusInternalServerError, "Could not mark box as consumed")
return
}
if !app.writeBoxZip(ctx, boxID, files) {
boxstore.DeleteBox(boxID)
return
}
boxstore.DeleteBox(boxID)
}
func (app *App) writeBoxZip(ctx *gin.Context, boxID string, files []models.BoxFile) bool {
writeBoxZipHeaders(ctx, boxID)
if err := writeBoxZipTo(ctx.Writer, boxID, files); err != nil {
ctx.Status(http.StatusInternalServerError)
return false
}
return true
}
func (app *App) handleRetryableOneTimeZip(ctx *gin.Context, boxID string, manifest models.BoxManifest, files []models.BoxFile) {
tempZip, err := os.CreateTemp("", "warpbox-"+boxID+"-*.zip")
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not prepare ZIP download")
return
}
tempPath := tempZip.Name()
defer os.Remove(tempPath)
if err := writeBoxZipTo(tempZip, boxID, files); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not build ZIP download")
return
}
if _, err := tempZip.Seek(0, 0); err != nil {
tempZip.Close()
ctx.String(http.StatusInternalServerError, "Could not read ZIP download")
return
}
writeBoxZipHeaders(ctx, boxID)
if _, err := io.Copy(ctx.Writer, tempZip); err != nil {
tempZip.Close()
return
}
if err := tempZip.Close(); err != nil {
return
}
manifest.Consumed = true
if err := boxstore.WriteManifest(boxID, manifest); err != nil {
return
}
boxstore.DeleteBox(boxID)
}
func writeBoxZipHeaders(ctx *gin.Context, boxID string) {
ctx.Header("Content-Type", "application/zip")
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="warpbox-%s.zip"`, boxID))
}
func writeBoxZipTo(destination io.Writer, boxID string, files []models.BoxFile) error {
zipWriter := zip.NewWriter(destination)
for _, file := range files {
if !file.IsComplete {
continue
}
if err := boxstore.AddFileToZip(zipWriter, boxID, file.Name); err != nil {
return err
}
}
if err := zipWriter.Close(); err != nil {
return err
}
return nil
}
func oneTimeDownloadLock(boxID string) *sync.Mutex {
lock, _ := oneTimeDownloadLocks.LoadOrStore(boxID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func allFilesComplete(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if !file.IsComplete {
return false
}
}
return true
}
func manifestFilesReady(files []models.BoxFile) bool {
if len(files) == 0 {
return false
}
for _, file := range files {
if file.Status != models.FileStatusReady {
return false
}
}
return true
}
func stripOneTimeThumbnailState(files []models.BoxFile) []models.BoxFile {
stripped := make([]models.BoxFile, 0, len(files))
for _, file := range files {
file.ThumbnailPath = nil
file.ThumbnailURL = ""
if file.ThumbnailStatus == "" {
file.ThumbnailStatus = models.ThumbnailStatusUnsupported
}
stripped = append(stripped, file)
}
return stripped
}
func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
if !boxstore.ValidBoxID(boxID) || !ok {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Individual downloads disabled for this box")
return
}
path, ok := boxstore.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
}
if !boxstore.IsSafeRegularBoxFile(boxID, filename) {
ctx.String(http.StatusBadRequest, "Invalid file")
return
}
ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
if hasManifest && manifest.OneTimeDownload {
ctx.String(http.StatusForbidden, "Thumbnails disabled for one-time boxes")
return
}
path, ok := boxstore.ThumbnailFilePath(boxID, fileID)
if !ok {
ctx.String(http.StatusBadRequest, "Invalid thumbnail")
return
}
if _, err := os.Stat(path); err != nil {
ctx.String(http.StatusNotFound, "Thumbnail not found")
return
}
ctx.Header("Content-Type", "image/jpeg")
ctx.File(path)
}
func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.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
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
files, err := boxstore.CreateManifest(boxID, request)
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 (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
file, err := ctx.FormFile("file")
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.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 (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
return
}
var request models.UpdateFileStatusRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
return
}
if request.Status == models.FileStatusReady {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
return
}
if err := app.rejectExpiredManifestBox(boxID); err != nil {
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
return
}
file, err := boxstore.MarkFileStatus(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 (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
if !boxstore.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
}
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveUpload(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 (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
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
}
totalSize := int64(0)
for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize += file.Size
}
if err := app.validateBoxSize(totalSize); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
return
}
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
if retentionKey == "" {
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
}
allowZip := true
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
allowZip = false
}
request := models.CreateBoxRequest{
RetentionKey: retentionKey,
Password: ctx.PostForm("password"),
AllowZip: &allowZip,
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
}
for _, file := range files {
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
manifestFiles, err := boxstore.CreateManifest(boxID, request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFiles := make([]models.BoxFile, 0, len(files))
for index, file := range files {
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
if err != nil {
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
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})
}
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return models.BoxManifest{}, false, true
}
if boxstore.IsExpired(manifest) {
boxstore.DeleteBox(boxID)
if wantsHTML {
ctx.String(http.StatusGone, "Box expired")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box expired"})
}
return manifest, true, false
}
if manifest.OneTimeDownload && manifest.Consumed {
if wantsHTML {
ctx.String(http.StatusGone, "Box already consumed")
} else {
ctx.JSON(http.StatusGone, gin.H{"error": "Box already consumed"})
}
return manifest, true, false
}
if boxstore.IsPasswordProtected(manifest) && !isBoxAuthorized(ctx, boxID, manifest) {
if wantsHTML {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID+"/login")
} else {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Password required"})
}
return manifest, true, false
}
if app.config.RenewOnAccessEnabled {
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
manifest = renewed
}
}
return manifest, true, true
}
func isBoxAuthorized(ctx *gin.Context, boxID string, manifest models.BoxManifest) bool {
token, err := ctx.Cookie(boxAuthCookieName(boxID))
return err == nil && boxstore.VerifyAuthToken(manifest, token)
}
func boxAuthCookieName(boxID string) string {
return boxAuthCookiePrefix + boxID
}
func (app *App) requireAPI(ctx *gin.Context) bool {
if app.config.APIEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
return false
}
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
if app.config.GuestUploadsEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
return false
}
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
if request == nil {
return nil
}
if !app.retentionAllowed(request.RetentionKey) {
return fmt.Errorf("Retention option is not allowed")
}
if !app.config.ZipDownloadsEnabled {
allowZip := false
request.AllowZip = &allowZip
}
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
return fmt.Errorf("One-time downloads are disabled")
}
totalSize := int64(0)
for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil {
return err
}
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return nil
}
totalSize := size
for _, file := range files {
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
}
if boxstore.IsExpired(manifest) {
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
totalSize := int64(0)
found := false
for _, file := range manifest.Files {
if file.ID == fileID {
totalSize += size
found = true
continue
}
totalSize += file.Size
}
if !found {
totalSize += size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateFileSize(size int64) error {
if size < 0 {
return fmt.Errorf("File size cannot be negative")
}
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
return fmt.Errorf("File exceeds the global max file size")
}
return nil
}
func (app *App) validateBoxSize(size int64) error {
if size < 0 {
return fmt.Errorf("Box size cannot be negative")
}
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
return fmt.Errorf("Box exceeds the global max box size")
}
return nil
}
func (app *App) rejectExpiredManifestBox(boxID string) error {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return nil
}
if !boxstore.IsExpired(manifest) {
return nil
}
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
func (app *App) limitRequestBody(ctx *gin.Context) {
limit := app.maxRequestBodyBytes()
if limit <= 0 {
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
}
func (app *App) maxRequestBodyBytes() int64 {
limit := app.config.GlobalMaxBoxSizeBytes
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
limit = app.config.GlobalMaxFileSizeBytes
}
if limit <= 0 {
return 0
}
return limit + 10*1024*1024
}
func (app *App) retentionAllowed(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return true
}
for _, option := range app.retentionOptions() {
if option.Key == key {
return true
}
}
return false
}
func (app *App) retentionOptions() []models.RetentionOption {
allOptions := boxstore.RetentionOptions()
options := make([]models.RetentionOption, 0, len(allOptions))
for _, option := range allOptions {
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
continue
}
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
continue
}
options = append(options, option)
}
if len(options) == 0 {
return allOptions[:1]
}
return options
}
func (app *App) defaultRetentionOption() models.RetentionOption {
options := app.retentionOptions()
for _, option := range options {
if option.Seconds == app.config.DefaultGuestExpirySeconds {
return option
}
}
return options[0]
}
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
"BoxID": boxID,
"BoxUser": "WarpBox\\" + boxID,
"ErrorMessage": errorMessage,
})
}

100
lib/server/pages.go Normal file
View File

@@ -0,0 +1,100 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
func formatBrowserTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": app.retentionOptions(),
"DefaultRetention": app.defaultRetentionOption().Key,
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
})
}
func (app *App) handleShowBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
ctx.String(http.StatusNotFound, "Box not found")
return
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
downloadAll := "/box/" + boxID + "/download"
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
downloadAll = ""
}
ctx.HTML(http.StatusOK, "box.html", gin.H{
"BoxID": boxID,
"Files": files,
"FileCount": len(files),
"DownloadAll": downloadAll,
"ZipOnly": hasManifest && manifest.OneTimeDownload,
"PollMS": app.config.BoxPollIntervalMS,
"RetentionLabel": manifest.RetentionLabel,
"ExpiresAt": manifest.ExpiresAt,
"ExpiresAtISO": formatBrowserTime(manifest.ExpiresAt),
})
}
func (app *App) handleBoxStatus(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, false)
if !ok {
return
}
var files []models.BoxFile
if hasManifest && manifestFilesReady(manifest.Files) {
files = boxstore.DecorateFiles(boxID, manifest.Files)
} else {
var err error
files, err = boxstore.ListFiles(boxID)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Box not found"})
return
}
}
if hasManifest && manifest.OneTimeDownload {
files = stripOneTimeThumbnailState(files)
}
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "expires_at": formatBrowserTime(manifest.ExpiresAt), "files": files})
}

49
lib/server/retention.go Normal file
View File

@@ -0,0 +1,49 @@
package server
import (
"strings"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
func (app *App) retentionAllowed(key string) bool {
key = strings.TrimSpace(key)
if key == "" {
return true
}
for _, option := range app.retentionOptions() {
if option.Key == key {
return true
}
}
return false
}
func (app *App) retentionOptions() []models.RetentionOption {
allOptions := boxstore.RetentionOptions()
options := make([]models.RetentionOption, 0, len(allOptions))
for _, option := range allOptions {
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
continue
}
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
continue
}
options = append(options, option)
}
if len(options) == 0 {
return allOptions[:1]
}
return options
}
func (app *App) defaultRetentionOption() models.RetentionOption {
options := app.retentionOptions()
for _, option := range options {
if option.Seconds == app.config.DefaultGuestExpirySeconds {
return option
}
}
return options[0]
}

236
lib/server/uploads.go Normal file
View File

@@ -0,0 +1,236 @@
package server
import (
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/models"
)
func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.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
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
files, err := boxstore.CreateManifest(boxID, request)
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 (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
return
}
file, err := ctx.FormFile("file")
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.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 (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) || !helpers.ValidLowerHexID(fileID, 16) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file"})
return
}
var request models.UpdateFileStatusRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status payload"})
return
}
if request.Status == models.FileStatusReady {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Uploads must complete through the upload endpoint"})
return
}
if err := app.rejectExpiredManifestBox(boxID); err != nil {
ctx.JSON(http.StatusGone, gin.H{"error": err.Error()})
return
}
file, err := boxstore.MarkFileStatus(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 (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
boxID := ctx.Param("id")
if !boxstore.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
}
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFile, err := boxstore.SaveUpload(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 (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
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
}
totalSize := int64(0)
for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize += file.Size
}
if err := app.validateBoxSize(totalSize); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
return
}
if err := os.MkdirAll(boxstore.BoxPath(boxID), 0755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not prepare upload box"})
return
}
retentionKey := strings.TrimSpace(ctx.PostForm("retention_key"))
if retentionKey == "" {
retentionKey = strings.TrimSpace(ctx.PostForm("retention"))
}
allowZip := true
if strings.EqualFold(strings.TrimSpace(ctx.PostForm("allow_zip")), "false") {
allowZip = false
}
request := models.CreateBoxRequest{
RetentionKey: retentionKey,
Password: ctx.PostForm("password"),
AllowZip: &allowZip,
Files: make([]models.CreateBoxFileRequest, 0, len(files)),
}
for _, file := range files {
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
}
if err := app.validateCreateBoxRequest(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
manifestFiles, err := boxstore.CreateManifest(boxID, request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedFiles := make([]models.BoxFile, 0, len(files))
for index, file := range files {
savedFile, err := boxstore.SaveManifestUpload(boxID, manifestFiles[index].ID, file)
if err != nil {
_, _ = boxstore.MarkFileStatus(boxID, manifestFiles[index].ID, models.FileStatusFailed)
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})
}

155
lib/server/validation.go Normal file
View File

@@ -0,0 +1,155 @@
package server
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/models"
)
func (app *App) requireAPI(ctx *gin.Context) bool {
if app.config.APIEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
return false
}
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
if app.config.GuestUploadsEnabled {
return true
}
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
return false
}
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
if request == nil {
return nil
}
if !app.retentionAllowed(request.RetentionKey) {
return fmt.Errorf("Retention option is not allowed")
}
if !app.config.ZipDownloadsEnabled {
allowZip := false
request.AllowZip = &allowZip
}
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
return fmt.Errorf("One-time downloads are disabled")
}
totalSize := int64(0)
for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil {
return err
}
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return nil
}
totalSize := size
for _, file := range files {
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return err
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
}
if boxstore.IsExpired(manifest) {
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
totalSize := int64(0)
found := false
for _, file := range manifest.Files {
if file.ID == fileID {
totalSize += size
found = true
continue
}
totalSize += file.Size
}
if !found {
totalSize += size
}
return app.validateBoxSize(totalSize)
}
func (app *App) validateFileSize(size int64) error {
if size < 0 {
return fmt.Errorf("File size cannot be negative")
}
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
return fmt.Errorf("File exceeds the global max file size")
}
return nil
}
func (app *App) validateBoxSize(size int64) error {
if size < 0 {
return fmt.Errorf("Box size cannot be negative")
}
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
return fmt.Errorf("Box exceeds the global max box size")
}
return nil
}
func (app *App) rejectExpiredManifestBox(boxID string) error {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return nil
}
if !boxstore.IsExpired(manifest) {
return nil
}
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
func (app *App) limitRequestBody(ctx *gin.Context) {
limit := app.maxRequestBodyBytes()
if limit <= 0 {
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, limit)
}
func (app *App) maxRequestBodyBytes() int64 {
limit := app.config.GlobalMaxBoxSizeBytes
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
limit = app.config.GlobalMaxFileSizeBytes
}
if limit <= 0 {
return 0
}
return limit + 10*1024*1024
}