feat: add upload policies, daily limits, and storage quotas
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s

- Add environment variables to configure anonymous uploads, daily upload caps, and default user storage limits.
- Update config loader to parse and validate the new settings.
- Implement backend logic to track daily usage and active storage per user.
- Update README and `.env.example` to document the new settings and admin panels.
This commit is contained in:
2026-05-30 17:23:20 +03:00
parent 9a3cb90b17
commit d77f164900
29 changed files with 1432 additions and 120 deletions

View File

@@ -48,23 +48,25 @@ type AuthService struct {
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
Status string `json:"status"`
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PublicUser struct {
ID string
Username string
Email string
Role string
Status string
CreatedAt time.Time
ID string
Username string
Email string
Role string
Status string
StorageQuotaMB *float64
CreatedAt time.Time
}
type Session struct {
@@ -366,6 +368,19 @@ func (s *AuthService) SetPassword(userID, password string) error {
return s.saveUser(user)
}
func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error {
if quotaMB != nil && *quotaMB <= 0 {
return fmt.Errorf("storage quota must be positive")
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.StorageQuotaMB = quotaMB
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) UserByID(id string) (User, error) {
var user User
err := s.db.View(func(tx *bbolt.Tx) error {
@@ -455,12 +470,13 @@ func (s *AuthService) CollectionByID(id string) (Collection, error) {
func (s *AuthService) PublicUser(user User) PublicUser {
return PublicUser{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
CreatedAt: user.CreatedAt,
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageQuotaMB: user.StorageQuotaMB,
CreatedAt: user.CreatedAt,
}
}

View File

@@ -0,0 +1,245 @@
package services
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
"go.etcd.io/bbolt"
"warpbox.dev/backend/libs/config"
)
var (
settingsBucket = []byte("settings")
usageBucket = []byte("usage")
)
var settingsKey = []byte("upload_policy")
type UploadPolicySettings struct {
AnonymousUploadsEnabled bool `json:"anonymousUploadsEnabled"`
AnonymousMaxUploadMB float64 `json:"anonymousMaxUploadMb"`
AnonymousDailyUploadMB float64 `json:"anonymousDailyUploadMb"`
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
UsageRetentionDays int `json:"usageRetentionDays"`
}
type UsageRecord struct {
Key string `json:"key"`
SubjectType string `json:"subjectType"`
Subject string `json:"subject"`
Date string `json:"date"`
UploadedBytes int64 `json:"uploadedBytes"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SettingsService struct {
db *bbolt.DB
defaults UploadPolicySettings
}
func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*SettingsService, error) {
service := &SettingsService{
db: db,
defaults: UploadPolicySettings{
AnonymousUploadsEnabled: defaults.AnonymousUploadsEnabled,
AnonymousMaxUploadMB: defaults.AnonymousMaxUploadMB,
AnonymousDailyUploadMB: defaults.AnonymousDailyUploadMB,
UserDailyUploadMB: defaults.UserDailyUploadMB,
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
UsageRetentionDays: defaults.UsageRetentionDays,
},
}
if err := service.validate(service.defaults); err != nil {
return nil, err
}
err := db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{settingsBucket, usageBucket} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
settings := s.defaults
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(settingsBucket).Get(settingsKey)
if data == nil {
return nil
}
return json.Unmarshal(data, &settings)
})
if err != nil {
return UploadPolicySettings{}, err
}
if err := s.validate(settings); err != nil {
return UploadPolicySettings{}, err
}
return settings, nil
}
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
if err := s.validate(settings); err != nil {
return err
}
data, err := json.Marshal(settings)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(settingsBucket).Put(settingsKey, data)
})
}
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
key := usageKey(subjectType, subject, now)
var record UsageRecord
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(usageBucket).Get([]byte(key))
if data == nil {
record = UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
return nil
}
return json.Unmarshal(data, &record)
})
return record, err
}
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
if bytes <= 0 {
return nil
}
key := usageKey(subjectType, subject, now)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(usageBucket)
record := UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
data := bucket.Get([]byte(key))
if data != nil {
if err := json.Unmarshal(data, &record); err != nil {
return err
}
}
record.UploadedBytes += bytes
record.UpdatedAt = now.UTC()
next, err := json.Marshal(record)
if err != nil {
return err
}
return bucket.Put([]byte(key), next)
})
}
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
if retentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive")
}
cutoff := now.UTC().AddDate(0, 0, -retentionDays)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(usageBucket)
return bucket.ForEach(func(key, value []byte) error {
var record UsageRecord
if err := json.Unmarshal(value, &record); err != nil {
return err
}
date, err := time.Parse("2006-01-02", record.Date)
if err != nil || date.Before(cutoff) {
return bucket.Delete(key)
}
return nil
})
})
}
func (s *SettingsService) UsageForUser(userID string, now time.Time) (UsageRecord, error) {
return s.Usage("user", userID, now)
}
func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, error) {
return s.Usage("ip", ip, now)
}
func (s *SettingsService) validate(settings UploadPolicySettings) error {
if settings.AnonymousMaxUploadMB <= 0 {
return fmt.Errorf("anonymous max upload must be positive")
}
if settings.AnonymousDailyUploadMB <= 0 {
return fmt.Errorf("anonymous daily upload must be positive")
}
if settings.UserDailyUploadMB <= 0 {
return fmt.Errorf("user daily upload must be positive")
}
if settings.DefaultUserStorageMB <= 0 {
return fmt.Errorf("default user storage must be positive")
}
if settings.UsageRetentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive")
}
return nil
}
func ParseMegabytesValue(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("megabyte value is required")
}
value = strings.TrimSuffix(value, "MB")
value = strings.TrimSuffix(value, "Mb")
value = strings.TrimSuffix(value, "mb")
value = strings.TrimSpace(value)
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
if parsed <= 0 {
return 0, fmt.Errorf("megabyte value must be positive")
}
return parsed, nil
}
func MegabytesToBytes(value float64) int64 {
return int64(value * 1024 * 1024)
}
func FormatMegabytesFromBytes(value int64) string {
mb := float64(value) / 1024 / 1024
return FormatMegabytesLabel(mb)
}
func FormatMegabytesLabel(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
}
func usageKey(subjectType, subject string, now time.Time) string {
return subjectType + ":" + subject + ":" + usageDate(now)
}
func usageDate(now time.Time) string {
return now.UTC().Format("2006-01-02")
}
func ClientIP(remoteAddr, forwardedFor string) string {
if forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
host := remoteAddr
if strings.Contains(remoteAddr, ":") {
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = splitHost
}
}
return host
}

View File

@@ -0,0 +1,173 @@
package services
import (
"log/slog"
"path/filepath"
"testing"
"time"
"warpbox.dev/backend/libs/config"
)
func TestSettingsLoadDefaultsAndOverrides(t *testing.T) {
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if !policy.AnonymousUploadsEnabled || policy.AnonymousMaxUploadMB != 512 {
t.Fatalf("default policy = %+v", policy)
}
policy.AnonymousUploadsEnabled = false
policy.UserDailyUploadMB = 123
if err := settings.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
next, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if next.AnonymousUploadsEnabled || next.UserDailyUploadMB != 123 {
t.Fatalf("override policy = %+v", next)
}
}
func TestSettingsUseNewEnvDefaultsUntilSaved(t *testing.T) {
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
defer upload.Close()
first, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 111,
AnonymousDailyUploadMB: 222,
UserDailyUploadMB: 333,
DefaultUserStorageMB: 444,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService first returned error: %v", err)
}
firstPolicy, err := first.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy first returned error: %v", err)
}
if firstPolicy.AnonymousMaxUploadMB != 111 {
t.Fatalf("first AnonymousMaxUploadMB = %v, want 111", firstPolicy.AnonymousMaxUploadMB)
}
second, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 555,
AnonymousDailyUploadMB: 666,
UserDailyUploadMB: 777,
DefaultUserStorageMB: 888,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService second returned error: %v", err)
}
secondPolicy, err := second.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy second returned error: %v", err)
}
if secondPolicy.AnonymousMaxUploadMB != 555 {
t.Fatalf("second AnonymousMaxUploadMB = %v, want 555", secondPolicy.AnonymousMaxUploadMB)
}
if err := second.UpdateUploadPolicy(secondPolicy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
third, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 999,
AnonymousDailyUploadMB: 999,
UserDailyUploadMB: 999,
DefaultUserStorageMB: 999,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService third returned error: %v", err)
}
thirdPolicy, err := third.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy third returned error: %v", err)
}
if thirdPolicy.AnonymousMaxUploadMB != 555 {
t.Fatalf("third AnonymousMaxUploadMB = %v, want persisted 555", thirdPolicy.AnonymousMaxUploadMB)
}
}
func TestSettingsRejectInvalidMegabytes(t *testing.T) {
if _, err := ParseMegabytesValue("0"); err == nil {
t.Fatalf("ParseMegabytesValue accepted zero")
}
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.DefaultUserStorageMB = -1
if err := settings.UpdateUploadPolicy(policy); err == nil {
t.Fatalf("UpdateUploadPolicy accepted negative storage")
}
}
func TestDailyUsageAndCleanup(t *testing.T) {
settings := newTestSettingsService(t)
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
if err := settings.AddUsage("ip", "127.0.0.1", 1024, now); err != nil {
t.Fatalf("AddUsage returned error: %v", err)
}
if err := settings.AddUsage("ip", "127.0.0.1", 2048, now); err != nil {
t.Fatalf("AddUsage returned error: %v", err)
}
usage, err := settings.UsageForIP("127.0.0.1", now)
if err != nil {
t.Fatalf("UsageForIP returned error: %v", err)
}
if usage.UploadedBytes != 3072 {
t.Fatalf("UploadedBytes = %d, want 3072", usage.UploadedBytes)
}
if err := settings.CleanupUsage(now.AddDate(0, 0, 31), 30); err != nil {
t.Fatalf("CleanupUsage returned error: %v", err)
}
usage, err = settings.UsageForIP("127.0.0.1", now)
if err != nil {
t.Fatalf("UsageForIP returned error: %v", err)
}
if usage.UploadedBytes != 0 {
t.Fatalf("UploadedBytes after cleanup = %d, want 0", usage.UploadedBytes)
}
}
func newTestSettingsService(t *testing.T) *SettingsService {
t.Helper()
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := upload.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
settings, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 512,
AnonymousDailyUploadMB: 2048,
UserDailyUploadMB: 8192,
DefaultUserStorageMB: 51200,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService returned error: %v", err)
}
return settings
}

View File

@@ -384,15 +384,27 @@ func (s *UploadService) UserBoxes(userID string, collectionNames map[string]stri
}
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, false)
}
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, true)
}
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
var total int64
now := time.Now().UTC()
for _, box := range boxes {
if box.OwnerID != userID {
continue
}
if activeOnly && !box.ExpiresAt.After(now) {
continue
}
for _, file := range box.Files {
total += file.Size
}

View File

@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestDeleteTokenVerification(t *testing.T) {
@@ -59,6 +60,39 @@ func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
}
}
func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
service := newTestUploadService(t)
active, err := service.CreateBox(testFileHeaders(t, "file", "active.txt", "active"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
if err != nil {
t.Fatalf("CreateBox active returned error: %v", err)
}
expired, err := service.CreateBox(testFileHeaders(t, "file", "expired.txt", "expired"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
if err != nil {
t.Fatalf("CreateBox expired returned error: %v", err)
}
expiredBox, err := service.GetBox(expired.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
expiredBox.ExpiresAt = time.Now().UTC().Add(-time.Hour)
if err := service.SaveBox(expiredBox); err != nil {
t.Fatalf("SaveBox returned error: %v", err)
}
activeBox, err := service.GetBox(active.BoxID)
if err != nil {
t.Fatalf("GetBox active returned error: %v", err)
}
want := activeBox.Files[0].Size
got, err := service.UserActiveStorageUsed("user-1")
if err != nil {
t.Fatalf("UserActiveStorageUsed returned error: %v", err)
}
if got != want {
t.Fatalf("UserActiveStorageUsed = %d, want %d", got, want)
}
}
func newTestUploadService(t *testing.T) *UploadService {
t.Helper()
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))