docs: expand configuration docs for admin and BadgerDB

Update README to explain startup config precedence (defaults/env/admin overrides),
document admin/bootstrap and feature toggles, and clarify storage locations under
WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to
include new config and metastore packages.docs: expand configuration docs for admin and BadgerDB

Update README to explain startup config precedence (defaults/env/admin overrides),
document admin/bootstrap and feature toggles, and clarify storage locations under
WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to
include new config and metastore packages.
This commit is contained in:
2026-04-28 21:11:37 +03:00
parent fc3de58b5b
commit a5d6d69be0
27 changed files with 3499 additions and 97 deletions

View File

@@ -13,6 +13,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -22,13 +23,15 @@ import (
)
const (
UploadRoot = "data/uploads"
manifestFile = ".warpbox.json"
OneTimeDownloadRetentionKey = "one-time"
)
var manifestMu sync.Mutex
var (
uploadRoot = filepath.Join("data", "uploads")
manifestMu sync.Mutex
)
var retentionOptions = []models.RetentionOption{
{Key: "10s", Label: "10 seconds", Seconds: 10},
@@ -58,8 +61,19 @@ func DefaultRetentionOption() models.RetentionOption {
return retentionOptions[0]
}
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)
return filepath.Join(uploadRoot, boxID)
}
func ManifestPath(boxID string) string {
@@ -74,6 +88,71 @@ func DeleteBox(boxID string) error {
return os.RemoveAll(BoxPath(boxID))
}
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 {
files := make([]models.BoxFile, 0, len(manifest.Files))
@@ -240,6 +319,21 @@ func WriteManifest(boxID string, manifest models.BoxManifest) error {
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 {
source, err := os.Open(filepath.Join(BoxPath(boxID), filename))
if err != nil {

View File

@@ -4,8 +4,8 @@ import (
"image"
"image/color"
"image/draw"
"image/jpeg"
_ "image/gif"
"image/jpeg"
_ "image/png"
"net/url"
"os"
@@ -66,7 +66,7 @@ func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
}
func collectThumbnailTasks(batchSize int) []thumbnailTask {
entries, err := os.ReadDir(UploadRoot)
entries, err := os.ReadDir(uploadRoot)
if err != nil {
return nil
}

523
lib/config/config.go Normal file
View File

@@ -0,0 +1,523 @@
package config
import (
"fmt"
"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"
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
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: 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,
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},
{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},
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", 0, &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", 0, &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", 0, &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", 0, &cfg.DefaultUserMaxBoxSizeBytes},
{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
}
}
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(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) 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 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"
}

145
lib/config/config_test.go Normal file
View File

@@ -0,0 +1,145 @@
package config
import (
"path/filepath"
"testing"
)
func TestDefaults(t *testing.T) {
clearConfigEnv(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.UploadsDir != filepath.Join("data", "uploads") {
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
}
if cfg.DBDir != filepath.Join("data", "db") {
t.Fatalf("unexpected db dir: %s", cfg.DBDir)
}
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
t.Fatal("expected default guest/API/download toggles to be enabled")
}
if cfg.AdminUsername != "admin" {
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
}
if cfg.AdminPassword != "" {
t.Fatal("expected default admin password to be empty")
}
}
func TestEnvironmentOverrides(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
t.Setenv("WARPBOX_API_ENABLED", "false")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.UploadsDir != filepath.Join("/tmp/warpbox-test", "uploads") {
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
}
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
t.Fatal("expected boolean environment overrides to be applied")
}
if cfg.GlobalMaxFileSizeBytes != 100 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.BoxPollIntervalMS != 2000 {
t.Fatalf("unexpected poll interval: %d", cfg.BoxPollIntervalMS)
}
if cfg.AdminUsername != "root" {
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
}
if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
}
}
func TestInvalidEnvironmentValues(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_SESSION_TTL_SECONDS", "1")
if _, err := Load(); err == nil {
t.Fatal("expected invalid session ttl to fail")
}
clearConfigEnv(t)
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "maybe")
if _, err := Load(); err == nil {
t.Fatal("expected invalid boolean to fail")
}
}
func TestSettingsOverridePrecedence(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_API_ENABLED", "true")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverrides(map[string]string{SettingAPIEnabled: "false"}); err != nil {
t.Fatalf("ApplyOverrides returned error: %v", err)
}
if cfg.APIEnabled {
t.Fatal("expected DB override to beat environment value")
}
if cfg.Source(SettingAPIEnabled) != SourceDB {
t.Fatalf("expected DB source, got %s", cfg.Source(SettingAPIEnabled))
}
}
func TestSettingsOverrideValidation(t *testing.T) {
clearConfigEnv(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
t.Fatal("expected negative expiry override to fail")
}
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
t.Fatal("expected hard limit override to fail")
}
}
func clearConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"WARPBOX_DATA_DIR",
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
"WARPBOX_GUEST_UPLOADS_ENABLED",
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}

View File

@@ -0,0 +1,71 @@
package metastore
import (
"strings"
"warpbox/lib/config"
)
func BootstrapAdmin(cfg *config.Config, store *Store) (BootstrapResult, error) {
adminTag, err := store.EnsureAdminTag()
if err != nil {
return BootstrapResult{}, err
}
var adminUser *User
user, ok, err := store.GetUserByUsername(cfg.AdminUsername)
if err != nil {
return BootstrapResult{}, err
}
if ok {
if !hasString(user.TagIDs, adminTag.ID) {
user.TagIDs = append(user.TagIDs, adminTag.ID)
if err := store.UpdateUser(user); err != nil {
return BootstrapResult{}, err
}
}
adminUser = &user
} else if strings.TrimSpace(cfg.AdminPassword) != "" {
created, err := store.CreateUserWithPassword(cfg.AdminUsername, cfg.AdminEmail, cfg.AdminPassword, []string{adminTag.ID})
if err != nil {
return BootstrapResult{}, err
}
adminUser = &created
}
hasAdminUser, err := store.HasAdminUser(adminTag.ID)
if err != nil {
return BootstrapResult{}, err
}
return BootstrapResult{
AdminTag: adminTag,
AdminUser: adminUser,
AdminLoginEnabled: cfg.AdminLoginEnabled(hasAdminUser),
}, nil
}
func (store *Store) HasAdminUser(adminTagID string) (bool, error) {
users, err := store.ListUsers()
if err != nil {
return false, err
}
for _, user := range users {
if user.Disabled {
continue
}
if hasString(user.TagIDs, adminTagID) {
return true, nil
}
}
return false, nil
}
func hasString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,222 @@
package metastore
import (
"errors"
"testing"
"time"
"warpbox/lib/config"
)
func TestOpenClose(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := store.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}
func TestBootstrapAdminFromPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret-pass")
t.Setenv("WARPBOX_ADMIN_EMAIL", "admin@example.test")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if !result.AdminLoginEnabled {
t.Fatal("expected admin login to be enabled")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to be protected")
}
if result.AdminUser == nil {
t.Fatal("expected bootstrap admin user")
}
if !hasString(result.AdminUser.TagIDs, result.AdminTag.ID) {
t.Fatal("expected bootstrap admin to have admin tag")
}
if !VerifyPassword(result.AdminUser.PasswordHash, "secret-pass") {
t.Fatal("expected bootstrap admin password to verify")
}
}
func TestBootstrapAdminDisabledWithoutPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if result.AdminLoginEnabled {
t.Fatal("expected admin login to be disabled without password or existing admin")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to still be created")
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("ListUsers returned error: %v", err)
}
if len(users) != 0 {
t.Fatalf("expected no users, got %d", len(users))
}
}
func TestDuplicateUsersAndTags(t *testing.T) {
store := openTestStore(t)
if _, err := store.CreateUserWithPassword("alex", "alex@example.test", "secret", nil); err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
if _, err := store.CreateUserWithPassword("Alex", "other@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate username error, got %v", err)
}
if _, err := store.CreateUserWithPassword("other", "alex@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate email error, got %v", err)
}
tag := Tag{Name: "staff"}
if err := store.CreateTag(&tag); err != nil {
t.Fatalf("CreateTag returned error: %v", err)
}
duplicate := Tag{Name: "Staff"}
if err := store.CreateTag(&duplicate); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate tag error, got %v", err)
}
}
func TestPermissionResolutionAndGlobalCaps(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "50")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "1000")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
tagFileLimit := int64(80)
tagBoxLimit := int64(2000)
userFileLimit := int64(60)
user := User{MaxFileSizeBytes: &userFileLimit}
tags := []Tag{
{
Permissions: TagPermissions{
UploadAllowed: true,
AllowedExpirySeconds: []int64{3600, 600},
MaxFileSizeBytes: &tagFileLimit,
MaxBoxSizeBytes: &tagBoxLimit,
ZipDownloadAllowed: true,
},
},
}
perms := ResolveUserPermissions(cfg, user, tags)
if !perms.UploadAllowed || !perms.ZipDownloadAllowed {
t.Fatal("expected tag booleans to grant permissions")
}
if perms.MaxFileSizeBytes != 80 {
t.Fatalf("expected tag limit to beat user/default limit, got %d", perms.MaxFileSizeBytes)
}
if perms.MaxBoxSizeBytes != 1000 {
t.Fatalf("expected global max box cap, got %d", perms.MaxBoxSizeBytes)
}
if len(perms.AllowedExpirySeconds) != 2 || perms.AllowedExpirySeconds[0] != 600 || perms.AllowedExpirySeconds[1] != 3600 {
t.Fatalf("unexpected expiry durations: %#v", perms.AllowedExpirySeconds)
}
}
func TestSettingsStorageAndPrecedence(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_API_ENABLED", "true")
store := openTestStore(t)
if err := store.SetSetting(config.SettingAPIEnabled, "false"); err != nil {
t.Fatalf("SetSetting returned error: %v", err)
}
overrides, err := store.ListSettings()
if err != nil {
t.Fatalf("ListSettings returned error: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverrides(overrides); err != nil {
t.Fatalf("ApplyOverrides returned error: %v", err)
}
if cfg.APIEnabled {
t.Fatal("expected stored DB override to beat env")
}
}
func TestSessionExpiry(t *testing.T) {
store := openTestStore(t)
session, err := store.CreateSession("user-id", time.Millisecond)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
time.Sleep(2 * time.Millisecond)
if _, ok, err := store.GetSession(session.Token); err != nil || ok {
t.Fatalf("expected expired session to be invalid, ok=%v err=%v", ok, err)
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func clearMetastoreConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"WARPBOX_DATA_DIR",
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
"WARPBOX_GUEST_UPLOADS_ENABLED",
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}

75
lib/metastore/models.go Normal file
View File

@@ -0,0 +1,75 @@
package metastore
import "time"
const AdminTagName = "admin"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Protected bool `json:"protected"`
Permissions TagPermissions `json:"permissions"`
}
type TagPermissions struct {
UploadAllowed bool `json:"upload_allowed"`
AllowedExpirySeconds []int64 `json:"allowed_expiry_seconds,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
OneTimeDownloadAllowed bool `json:"one_time_download_allowed"`
ZipDownloadAllowed bool `json:"zip_download_allowed"`
RenewableAllowed bool `json:"renewable_allowed"`
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
AdminAccess bool `json:"admin_access"`
AdminUsersManage bool `json:"admin_users_manage"`
AdminSettingsManage bool `json:"admin_settings_manage"`
AdminBoxesView bool `json:"admin_boxes_view"`
}
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type EffectivePermissions struct {
UploadAllowed bool
AllowedExpirySeconds []int64
MaxFileSizeBytes int64
MaxBoxSizeBytes int64
MaxExpirySeconds int64
OneTimeDownloadAllowed bool
ZipDownloadAllowed bool
RenewableAllowed bool
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
}
type BootstrapResult struct {
AdminTag Tag
AdminUser *User
AdminLoginEnabled bool
}

View File

@@ -0,0 +1,141 @@
package metastore
import (
"sort"
"warpbox/lib/config"
)
func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) EffectivePermissions {
perms := EffectivePermissions{
MaxFileSizeBytes: cfg.DefaultUserMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.DefaultUserMaxBoxSizeBytes,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
}
expirySet := make(map[int64]bool)
for _, tag := range tags {
tagPerms := tag.Permissions
perms.UploadAllowed = perms.UploadAllowed || tagPerms.UploadAllowed
perms.OneTimeDownloadAllowed = perms.OneTimeDownloadAllowed || tagPerms.OneTimeDownloadAllowed
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
perms.RenewOnAccessSeconds = maxInt64(perms.RenewOnAccessSeconds, tagPerms.RenewOnAccessSeconds)
perms.RenewOnDownloadSeconds = maxInt64(perms.RenewOnDownloadSeconds, tagPerms.RenewOnDownloadSeconds)
if tagPerms.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *tagPerms.MaxFileSizeBytes)
}
if tagPerms.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *tagPerms.MaxBoxSizeBytes)
}
for _, seconds := range tagPerms.AllowedExpirySeconds {
if seconds >= 0 {
expirySet[seconds] = true
}
}
}
if user.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *user.MaxFileSizeBytes)
}
if user.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *user.MaxBoxSizeBytes)
}
if user.MaxExpirySeconds != nil {
perms.MaxExpirySeconds = *user.MaxExpirySeconds
}
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
if !cfg.ZipDownloadsEnabled {
perms.ZipDownloadAllowed = false
}
if !cfg.OneTimeDownloadsEnabled {
perms.OneTimeDownloadAllowed = false
}
return perms
}
func ResolveGuestPermissions(cfg *config.Config) EffectivePermissions {
return EffectivePermissions{
UploadAllowed: cfg.GuestUploadsEnabled,
AllowedExpirySeconds: guestExpirySeconds(cfg),
MaxFileSizeBytes: cfg.GlobalMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.GlobalMaxBoxSizeBytes,
MaxExpirySeconds: cfg.MaxGuestExpirySeconds,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
RenewableAllowed: cfg.RenewOnAccessEnabled || cfg.RenewOnDownloadEnabled,
}
}
func morePermissiveLimit(current int64, candidate int64) int64 {
if current == 0 || candidate == 0 {
return 0
}
if candidate > current {
return candidate
}
return current
}
func capLimit(value int64, globalMax int64) int64 {
if globalMax == 0 {
return value
}
if value == 0 || value > globalMax {
return globalMax
}
return value
}
func sortedExpirySet(expirySet map[int64]bool) []int64 {
values := make([]int64, 0, len(expirySet))
for value := range expirySet {
values = append(values, value)
}
sort.Slice(values, func(i int, j int) bool {
return values[i] < values[j]
})
return values
}
func guestExpirySeconds(cfg *config.Config) []int64 {
values := []int64{}
if cfg.DefaultGuestExpirySeconds >= 0 {
values = append(values, cfg.DefaultGuestExpirySeconds)
}
if cfg.MaxGuestExpirySeconds > 0 && cfg.MaxGuestExpirySeconds != cfg.DefaultGuestExpirySeconds {
values = append(values, cfg.MaxGuestExpirySeconds)
}
return uniqueInt64s(values)
}
func uniqueInt64s(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i int, j int) bool {
return out[i] < out[j]
})
return out
}
func maxInt64(a int64, b int64) int64 {
if b > a {
return b
}
return a
}

74
lib/metastore/sessions.go Normal file
View File

@@ -0,0 +1,74 @@
package metastore
import (
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func (store *Store) CreateSession(userID string, ttl time.Duration) (Session, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return Session{}, fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
if ttl <= 0 {
return Session{}, fmt.Errorf("%w: session ttl must be positive", ErrInvalid)
}
token, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
now := time.Now().UTC()
session := Session{
Token: token,
UserID: userID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, sessionKey(token), session)
})
return session, err
}
func (store *Store) GetSession(token string) (Session, bool, error) {
token = strings.TrimSpace(token)
if token == "" {
return Session{}, false, nil
}
var session Session
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, sessionKey(token), &session)
})
if errors.Is(err, ErrNotFound) {
return Session{}, false, nil
}
if err != nil {
return Session{}, false, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = store.DeleteSession(token)
return Session{}, false, nil
}
return session, true, nil
}
func (store *Store) DeleteSession(token string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(sessionKey(token))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func sessionKey(token string) []byte {
return []byte("session/" + strings.TrimSpace(token))
}

379
lib/metastore/store.go Normal file
View File

@@ -0,0 +1,379 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
)
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate")
ErrInvalid = errors.New("invalid")
)
type Store struct {
db *badger.DB
}
func Open(path string) (*Store, error) {
opts := badger.DefaultOptions(path).WithLogger(nil)
db, err := badger.Open(opts)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (store *Store) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
func (store *Store) SetSetting(name string, value string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: setting name cannot be empty", ErrInvalid)
}
return store.db.Update(func(txn *badger.Txn) error {
return txn.Set(settingKey(name), []byte(value))
})
}
func (store *Store) DeleteSetting(name string) error {
return store.db.Update(func(txn *badger.Txn) error {
return txn.Delete(settingKey(name))
})
}
func (store *Store) GetSetting(name string) (string, bool, error) {
var value string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(settingKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
value = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return "", false, nil
}
return value, err == nil, err
}
func (store *Store) ListSettings() (map[string]string, error) {
settings := make(map[string]string)
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("setting/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
name := strings.TrimPrefix(string(item.Key()), "setting/")
if err := item.Value(func(data []byte) error {
settings[name] = string(data)
return nil
}); err != nil {
return err
}
}
return nil
})
return settings, err
}
func HashPassword(password string) (string, error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("%w: password cannot be empty", ErrInvalid)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPassword(hash string, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (store *Store) CreateUserWithPassword(username string, email string, password string, tagIDs []string) (User, error) {
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
user := User{
Username: username,
Email: email,
PasswordHash: hash,
TagIDs: uniqueStrings(tagIDs),
}
if err := store.CreateUser(&user); err != nil {
return User{}, err
}
return user, nil
}
func (store *Store) CreateUser(user *User) error {
if user == nil {
return fmt.Errorf("%w: user cannot be nil", ErrInvalid)
}
username := strings.TrimSpace(user.Username)
if username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
email := strings.TrimSpace(user.Email)
if user.PasswordHash == "" {
return fmt.Errorf("%w: password hash cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if user.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
user.ID = id
}
user.Username = username
user.Email = email
user.TagIDs = uniqueStrings(user.TagIDs)
user.CreatedAt = now
user.UpdatedAt = now
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, usernameKey(username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if email != "" {
if exists, err := keyExists(txn, emailKey(email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
}
if err := putJSON(txn, userKey(user.ID), user); err != nil {
return err
}
if err := txn.Set(usernameKey(username), []byte(user.ID)); err != nil {
return err
}
if email != "" {
return txn.Set(emailKey(email), []byte(user.ID))
}
return nil
})
}
func (store *Store) UpdateUser(user User) error {
if strings.TrimSpace(user.ID) == "" {
return fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
user.Username = strings.TrimSpace(user.Username)
user.Email = strings.TrimSpace(user.Email)
if user.Username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
user.TagIDs = uniqueStrings(user.TagIDs)
user.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var existing User
if err := getJSON(txn, userKey(user.ID), &existing); err != nil {
return err
}
oldUsername := normalizeIndex(existing.Username)
newUsername := normalizeIndex(user.Username)
if oldUsername != newUsername {
if exists, err := keyExists(txn, usernameKey(user.Username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if err := txn.Delete(usernameKey(existing.Username)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(usernameKey(user.Username), []byte(user.ID)); err != nil {
return err
}
}
oldEmail := normalizeIndex(existing.Email)
newEmail := normalizeIndex(user.Email)
if oldEmail != newEmail {
if newEmail != "" {
if exists, err := keyExists(txn, emailKey(user.Email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
if err := txn.Set(emailKey(user.Email), []byte(user.ID)); err != nil {
return err
}
}
if oldEmail != "" {
if err := txn.Delete(emailKey(existing.Email)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
}
}
return putJSON(txn, userKey(user.ID), user)
})
}
func (store *Store) GetUser(id string) (User, bool, error) {
var user User
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, userKey(id), &user)
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
return user, err == nil, err
}
func (store *Store) GetUserByUsername(username string) (User, bool, error) {
return store.getUserByIndex(usernameKey(username))
}
func (store *Store) GetUserByEmail(email string) (User, bool, error) {
return store.getUserByIndex(emailKey(email))
}
func (store *Store) ListUsers() ([]User, error) {
users := []User{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("user/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var user User
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &user)
}); err != nil {
return err
}
users = append(users, user)
}
return nil
})
return users, err
}
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
return store.GetUser(id)
}
func putJSON(txn *badger.Txn, key []byte, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return txn.Set(key, data)
}
func getJSON(txn *badger.Txn, key []byte, value any) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
return json.Unmarshal(data, value)
})
}
func keyExists(txn *badger.Txn, key []byte) (bool, error) {
_, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return false, nil
}
return err == nil, err
}
func settingKey(name string) []byte {
return []byte("setting/" + strings.TrimSpace(name))
}
func userKey(id string) []byte {
return []byte("user/" + strings.TrimSpace(id))
}
func usernameKey(username string) []byte {
return []byte("user_by_name/" + normalizeIndex(username))
}
func emailKey(email string) []byte {
return []byte("user_by_email/" + normalizeIndex(email))
}
func normalizeIndex(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}

220
lib/metastore/tags.go Normal file
View File

@@ -0,0 +1,220 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func AdminPermissions() TagPermissions {
unlimited := int64(0)
return TagPermissions{
UploadAllowed: true,
MaxFileSizeBytes: &unlimited,
MaxBoxSizeBytes: &unlimited,
OneTimeDownloadAllowed: true,
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,
}
}
func (store *Store) EnsureAdminTag() (Tag, error) {
tag, ok, err := store.GetTagByName(AdminTagName)
if err != nil {
return Tag{}, err
}
if ok {
tag.Protected = true
tag.Permissions = AdminPermissions()
tag.Description = "Built-in administrator permissions"
if err := store.UpdateTag(tag); err != nil {
return Tag{}, err
}
return tag, nil
}
tag = Tag{
Name: AdminTagName,
Description: "Built-in administrator permissions",
Protected: true,
Permissions: AdminPermissions(),
}
if err := store.CreateTag(&tag); err != nil {
return Tag{}, err
}
return tag, nil
}
func (store *Store) CreateTag(tag *Tag) error {
if tag == nil {
return fmt.Errorf("%w: tag cannot be nil", ErrInvalid)
}
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if tag.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
tag.ID = id
}
tag.CreatedAt = now
tag.UpdatedAt = now
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := putJSON(txn, tagKey(tag.ID), tag); err != nil {
return err
}
return txn.Set(tagNameKey(tag.Name), []byte(tag.ID))
})
}
func (store *Store) UpdateTag(tag Tag) error {
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.ID == "" {
return fmt.Errorf("%w: tag id cannot be empty", ErrInvalid)
}
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
tag.UpdatedAt = time.Now().UTC()
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
var existing Tag
if err := getJSON(txn, tagKey(tag.ID), &existing); err != nil {
return err
}
if normalizeIndex(existing.Name) != normalizeIndex(tag.Name) {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := txn.Delete(tagNameKey(existing.Name)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(tagNameKey(tag.Name), []byte(tag.ID)); err != nil {
return err
}
}
if existing.Protected {
tag.Protected = true
}
if tag.Name == AdminTagName {
tag.Protected = true
tag.Permissions = AdminPermissions()
}
return putJSON(txn, tagKey(tag.ID), tag)
})
}
func (store *Store) GetTag(id string) (Tag, bool, error) {
var tag Tag
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, tagKey(id), &tag)
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
return tag, err == nil, err
}
func (store *Store) GetTagByName(name string) (Tag, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(tagNameKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
if err != nil {
return Tag{}, false, err
}
return store.GetTag(id)
}
func (store *Store) ListTags() ([]Tag, error) {
tags := []Tag{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("tag/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var tag Tag
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &tag)
}); err != nil {
return err
}
tags = append(tags, tag)
}
return nil
})
return tags, err
}
func (store *Store) TagsByID(ids []string) ([]Tag, error) {
tags := make([]Tag, 0, len(ids))
for _, id := range ids {
tag, ok, err := store.GetTag(id)
if err != nil {
return nil, err
}
if ok {
tags = append(tags, tag)
}
}
return tags, nil
}
func normalizeTagPermissions(perms *TagPermissions) {
if perms == nil {
return
}
perms.AllowedExpirySeconds = uniqueInt64s(perms.AllowedExpirySeconds)
}
func tagKey(id string) []byte {
return []byte("tag/" + strings.TrimSpace(id))
}
func tagNameKey(name string) []byte {
return []byte("tag_by_name/" + normalizeIndex(name))
}

View File

@@ -54,6 +54,18 @@ type BoxManifest struct {
OneTimeDownload bool `json:"one_time_download,omitempty"`
}
type BoxSummary struct {
ID string
FileCount int
TotalSize int64
TotalSizeLabel string
CreatedAt time.Time
ExpiresAt time.Time
Expired bool
OneTimeDownload bool
PasswordProtected bool
}
type CreateBoxRequest struct {
Files []CreateBoxFileRequest `json:"files"`
RetentionKey string `json:"retention_key"`

562
lib/server/admin.go Normal file
View File

@@ -0,0 +1,562 @@
package server
import (
"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.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),
})
}
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),
"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),
"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
}
}
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),
"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
}
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.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) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"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"
}
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

@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -17,21 +18,24 @@ import (
const boxAuthCookiePrefix = "warpbox_box_"
func handleIndex(ctx *gin.Context) {
func (app *App) handleIndex(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RetentionOptions": boxstore.RetentionOptions(),
"DefaultRetention": boxstore.DefaultRetentionOption().Key,
"RetentionOptions": app.retentionOptions(),
"DefaultRetention": app.defaultRetentionOption().Key,
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
})
}
func handleShowBox(ctx *gin.Context) {
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 := authorizeBoxRequest(ctx, boxID, true)
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
@@ -43,7 +47,7 @@ func handleShowBox(ctx *gin.Context) {
}
downloadAll := "/box/" + boxID + "/download"
if hasManifest && manifest.DisableZip {
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
downloadAll = ""
}
@@ -53,7 +57,7 @@ func handleShowBox(ctx *gin.Context) {
"FileCount": len(files),
"DownloadAll": downloadAll,
"ZipOnly": hasManifest && manifest.OneTimeDownload,
"PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000),
"PollMS": app.config.BoxPollIntervalMS,
"RetentionLabel": manifest.RetentionLabel,
"ExpiresAt": manifest.ExpiresAt,
})
@@ -122,14 +126,18 @@ func handleBoxLoginPost(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
}
func handleBoxStatus(ctx *gin.Context) {
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
}
if _, _, ok := authorizeBoxRequest(ctx, boxID, false); !ok {
if _, _, ok := app.authorizeBoxRequest(ctx, boxID, false); !ok {
return
}
@@ -142,14 +150,19 @@ func handleBoxStatus(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "files": files})
}
func handleDownloadBox(ctx *gin.Context) {
func (app *App) handleDownloadBox(ctx *gin.Context) {
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.String(http.StatusBadRequest, "Invalid box id")
return
}
manifest, hasManifest, ok := authorizeBoxRequest(ctx, boxID, true)
if !app.config.ZipDownloadsEnabled {
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
return
}
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
if !ok {
return
}
@@ -200,6 +213,8 @@ func handleDownloadBox(ctx *gin.Context) {
if hasManifest && manifest.OneTimeDownload {
boxstore.DeleteBox(boxID)
} else if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
@@ -217,7 +232,7 @@ func allFilesComplete(files []models.BoxFile) bool {
return true
}
func handleDownloadFile(ctx *gin.Context) {
func (app *App) handleDownloadFile(ctx *gin.Context) {
boxID := ctx.Param("id")
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
if !boxstore.ValidBoxID(boxID) || !ok {
@@ -225,7 +240,7 @@ func handleDownloadFile(ctx *gin.Context) {
return
}
manifest, hasManifest, authorized := authorizeBoxRequest(ctx, boxID, true)
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
if !authorized {
return
}
@@ -246,9 +261,12 @@ func handleDownloadFile(ctx *gin.Context) {
}
ctx.FileAttachment(path, filename)
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func handleDownloadThumbnail(ctx *gin.Context) {
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
@@ -256,7 +274,7 @@ func handleDownloadThumbnail(ctx *gin.Context) {
return
}
if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized {
if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized {
return
}
@@ -275,7 +293,11 @@ func handleDownloadThumbnail(ctx *gin.Context) {
ctx.File(path)
}
func handleCreateBox(ctx *gin.Context) {
func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
@@ -292,6 +314,10 @@ func handleCreateBox(ctx *gin.Context) {
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 {
@@ -302,7 +328,11 @@ func handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
}
func handleManifestFileUpload(ctx *gin.Context) {
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
@@ -316,6 +346,11 @@ func handleManifestFileUpload(ctx *gin.Context) {
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 {
@@ -327,7 +362,11 @@ func handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func handleFileStatusUpdate(ctx *gin.Context) {
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
if !app.requireAPI(ctx) {
return
}
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
if !boxstore.ValidBoxID(boxID) {
@@ -350,7 +389,11 @@ func handleFileStatusUpdate(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
func handleDirectBoxUpload(ctx *gin.Context) {
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
@@ -362,6 +405,10 @@ func handleDirectBoxUpload(ctx *gin.Context) {
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 {
@@ -372,7 +419,11 @@ func handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
func handleLegacyUpload(ctx *gin.Context) {
func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
form, err := ctx.MultipartForm()
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
@@ -384,6 +435,18 @@ func handleLegacyUpload(ctx *gin.Context) {
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 {
@@ -410,7 +473,7 @@ func handleLegacyUpload(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
}
func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
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
@@ -435,6 +498,12 @@ func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models
return manifest, true, false
}
if app.config.RenewOnAccessEnabled {
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
manifest = renewed
}
}
return manifest, true, true
}
@@ -447,6 +516,155 @@ 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
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
return nil
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
}
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) 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,

View File

@@ -1,42 +1,84 @@
package server
import (
"fmt"
"time"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/routing"
)
type App struct {
config *config.Config
store *metastore.Store
adminLoginEnabled bool
}
func Run(addr string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if err := cfg.EnsureDirectories(); err != nil {
return err
}
boxstore.SetUploadRoot(cfg.UploadsDir)
store, err := metastore.Open(cfg.DBDir)
if err != nil {
return fmt.Errorf("open metadata database: %w", err)
}
defer store.Close()
overrides, err := store.ListSettings()
if err != nil {
return fmt.Errorf("load settings overrides: %w", err)
}
if err := cfg.ApplyOverrides(overrides); err != nil {
return fmt.Errorf("apply settings overrides: %w", err)
}
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
return fmt.Errorf("bootstrap admin metadata: %w", err)
}
app := &App{
config: cfg,
store: store,
adminLoginEnabled: bootstrap.AdminLoginEnabled,
}
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
routing.Register(router, routing.Handlers{
Index: handleIndex,
ShowBox: handleShowBox,
Index: app.handleIndex,
ShowBox: app.handleShowBox,
BoxLogin: handleBoxLogin,
BoxLoginPost: handleBoxLoginPost,
BoxStatus: handleBoxStatus,
DownloadBox: handleDownloadBox,
DownloadFile: handleDownloadFile,
DownloadThumbnail: handleDownloadThumbnail,
CreateBox: handleCreateBox,
ManifestFileUpload: handleManifestFileUpload,
FileStatusUpdate: handleFileStatusUpdate,
DirectBoxUpload: handleDirectBoxUpload,
LegacyUpload: handleLegacyUpload,
BoxStatus: app.handleBoxStatus,
DownloadBox: app.handleDownloadBox,
DownloadFile: app.handleDownloadFile,
DownloadThumbnail: app.handleDownloadThumbnail,
CreateBox: app.handleCreateBox,
ManifestFileUpload: app.handleManifestFileUpload,
FileStatusUpdate: app.handleFileStatusUpdate,
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
})
app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static")
batchSize := helpers.EnvInt("WARPBOX_THUMBNAIL_BATCH_SIZE", 10, 1)
intervalSeconds := helpers.EnvInt("WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 30, 1)
boxstore.StartThumbnailWorker(batchSize, time.Duration(intervalSeconds)*time.Second)
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
return router.Run(addr)
}