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:
@@ -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 {
|
||||
|
||||
@@ -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
523
lib/config/config.go
Normal 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
145
lib/config/config_test.go
Normal 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, "")
|
||||
}
|
||||
}
|
||||
71
lib/metastore/bootstrap.go
Normal file
71
lib/metastore/bootstrap.go
Normal 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
|
||||
}
|
||||
222
lib/metastore/metastore_test.go
Normal file
222
lib/metastore/metastore_test.go
Normal 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
75
lib/metastore/models.go
Normal 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
|
||||
}
|
||||
141
lib/metastore/permissions.go
Normal file
141
lib/metastore/permissions.go
Normal 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
74
lib/metastore/sessions.go
Normal 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
379
lib/metastore/store.go
Normal 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
220
lib/metastore/tags.go
Normal 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))
|
||||
}
|
||||
@@ -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
562
lib/server/admin.go
Normal 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")
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user