cleanup(admin): This large-scale refactoring effort involves cleaning up redundant logic and standardizing database interactions across several modules.

This commit is contained in:
2026-04-30 22:06:45 +03:00
parent b8bb75f7e0
commit 9951cfc8b6
28 changed files with 13 additions and 2510 deletions

View File

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

View File

@@ -1,222 +0,0 @@
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, "")
}
}

View File

@@ -1,76 +0,0 @@
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"`
CSRFToken string `json:"csrf_token"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type EffectivePermissions struct {
UploadAllowed bool
AllowedExpirySeconds []int64
MaxFileSizeBytes int64
MaxBoxSizeBytes int64
MaxExpirySeconds int64
OneTimeDownloadAllowed bool
ZipDownloadAllowed bool
RenewableAllowed bool
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
}
type BootstrapResult struct {
AdminTag Tag
AdminUser *User
AdminLoginEnabled bool
}

View File

@@ -1,141 +0,0 @@
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
}

View File

@@ -1,79 +0,0 @@
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
}
csrfToken, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
now := time.Now().UTC()
session := Session{
Token: token,
CSRFToken: csrfToken,
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))
}

View File

@@ -1,379 +0,0 @@
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,12 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
@@ -40,40 +35,3 @@ func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
t.Fatalf("expected expired box to be deleted, stat err=%v", err)
}
}
func TestAdminProtectedPostRequiresCSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
store, err := metastore.Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
adminTag, err := store.EnsureAdminTag()
if err != nil {
t.Fatalf("EnsureAdminTag returned error: %v", err)
}
user, err := store.CreateUserWithPassword("admin", "", "secret", []string{adminTag.ID})
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
session, err := store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
app := &App{config: &config.Config{}, store: store}
router := gin.New()
router.POST("/admin/test", app.requireAdminSession, func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "/admin/test", nil)
request.AddCookie(&http.Cookie{Name: adminSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
}
}

View File

@@ -1,7 +1,6 @@
package server
import (
"fmt"
"time"
"github.com/gin-contrib/gzip"
@@ -9,14 +8,11 @@ import (
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/routing"
)
type App struct {
config *config.Config
store *metastore.Store
adminLoginEnabled bool
config *config.Config
}
func Run(addr string) error {
@@ -30,31 +26,7 @@ func Run(addr string) error {
applyBoxstoreRuntimeConfig(cfg)
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)
}
applyBoxstoreRuntimeConfig(cfg)
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,
}
app := &App{config: cfg}
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
@@ -74,7 +46,6 @@ func Run(addr string) error {
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
})
app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static")