diff --git a/cmd/server/main.go b/cmd/server/main.go index 2d942b6..540b25b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "ResendIt/internal/api/middleware" "ResendIt/internal/auth" + "ResendIt/internal/config" "ResendIt/internal/db" "ResendIt/internal/file" "ResendIt/internal/user" @@ -32,7 +33,7 @@ func main() { panic(fmt.Errorf("failed to connect database: %w", err)) } - err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}) + err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}, &config.ConfigEntry{}) if err != nil { fmt.Printf("Error migrating database: %v\n", err) return @@ -69,22 +70,33 @@ func main() { userService := user.NewService(userRepo) userHandler := user.NewHandler(userService) + configRepo := config.NewRepository(dbCon) + configService := config.NewService(configRepo) + fileRepo := file.NewRepository(dbCon) fileService := file.NewService(fileRepo, "./uploads") - fileHandler := file.NewHandler(fileService) + fileHandler := file.NewHandler(fileService, configService) createAdminUser(userService) apiRoute := r.Group("/api") // General API rate limiting to reduce abuse/spam. - // ~60 req/min per IP with some burst room. - apiRoute.Use(middleware.RateLimitByIP(60, time.Minute, 30, 5*time.Minute)) + apiRoute.Use(middleware.RateLimitByIPDynamic( + func() int { + return configService.GetIntDefault(config.KeyRateLimitApiPerMinute, config.DefaultRateLimitApiPerMinute) + }, + time.Minute, + func() int { + return configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst) + }, + 5*time.Minute, + )) - auth.RegisterRoutes(apiRoute, authHandler) + auth.RegisterRoutes(apiRoute, authHandler, configService) user.RegisterRoutes(apiRoute, userHandler) file.RegisterRoutes(apiRoute, fileHandler) - webHandler := web.NewHandler(fileService) + webHandler := web.NewHandler(fileService, configService) web.RegisterRoutes(r, webHandler, userService) port := os.Getenv("PORT") diff --git a/internal/api/middleware/ratelimit.go b/internal/api/middleware/ratelimit.go index 521e617..7f1eee8 100644 --- a/internal/api/middleware/ratelimit.go +++ b/internal/api/middleware/ratelimit.go @@ -48,6 +48,8 @@ func (b *tokenBucket) allow() bool { type ipClient struct { bucket *tokenBucket + max int + burst int lastSeen time.Time } @@ -58,6 +60,12 @@ type ipClient struct { // burst: optional burst capacity (defaults to max if <=0) // ttl: how long to keep idle IP buckets around func RateLimitByIP(max int, per time.Duration, burst int, ttl time.Duration) gin.HandlerFunc { + return RateLimitByIPDynamic(func() int { return max }, per, func() int { return burst }, ttl) +} + +// RateLimitByIPDynamic is like RateLimitByIP but reads max/burst dynamically. +// This allows changing limits at runtime (e.g. from an admin config page). +func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() int, ttl time.Duration) gin.HandlerFunc { var ( mu sync.Mutex clients = make(map[string]*ipClient) @@ -86,16 +94,22 @@ func RateLimitByIP(max int, per time.Duration, burst int, ttl time.Duration) gin } } - getClient := func(ip string, now time.Time) *ipClient { + getClient := func(ip string, now time.Time, max int, burst int) *ipClient { mu.Lock() defer mu.Unlock() c, ok := clients[ip] if !ok { - c = &ipClient{bucket: newTokenBucket(max, per, burst), lastSeen: now} + c = &ipClient{bucket: newTokenBucket(max, per, burst), max: max, burst: burst, lastSeen: now} clients[ip] = c return c } c.lastSeen = now + // If settings changed, reset the bucket. + if c.max != max || c.burst != burst { + c.bucket = newTokenBucket(max, per, burst) + c.max = max + c.burst = burst + } return c } @@ -104,7 +118,15 @@ func RateLimitByIP(max int, per time.Duration, burst int, ttl time.Duration) gin cleanup(now) ip := c.ClientIP() - client := getClient(ip, now) + max := maxFn() + if max <= 0 { + max = 1 + } + burst := burstFn() + if burst <= 0 { + burst = max + } + client := getClient(ip, now, max, burst) if !client.bucket.allow() { c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ diff --git a/internal/auth/routes.go b/internal/auth/routes.go index 6d1384b..798badd 100644 --- a/internal/auth/routes.go +++ b/internal/auth/routes.go @@ -2,17 +2,30 @@ package auth import ( "ResendIt/internal/api/middleware" + "ResendIt/internal/config" "time" "github.com/gin-gonic/gin" ) -func RegisterRoutes(r *gin.RouterGroup, h *Handler) { +type ConfigService interface { + GetIntDefault(key string, def int) int +} + +func RegisterRoutes(r *gin.RouterGroup, h *Handler, cfg ConfigService) { auth := r.Group("/auth") // Stricter rate limit on login to reduce brute-force / log spam. - // 5 attempts per minute per IP, burst 10. - auth.POST("/login", middleware.RateLimitByIP(5, time.Minute, 10, 15*time.Minute), h.Login) + auth.POST("/login", middleware.RateLimitByIPDynamic( + func() int { + return cfg.GetIntDefault(config.KeyRateLimitLoginPerMinute, config.DefaultRateLimitLoginPerMinute) + }, + time.Minute, + func() int { + return cfg.GetIntDefault(config.KeyRateLimitLoginBurst, config.DefaultRateLimitLoginBurst) + }, + 15*time.Minute, + ), h.Login) protected := auth.Group("/") protected.Use(middleware.AuthMiddleware()) diff --git a/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 0000000..a6ede89 --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1,23 @@ +package config + +const ( + KeyUploadMaxFileSizeBytes = "upload.max_file_size_bytes" + KeyUploadMultiMaxFiles = "upload.multi.max_files" + KeyUploadMaxHours = "upload.max_hours" + KeyRateLimitLoginPerMinute = "ratelimit.login.per_minute" + KeyRateLimitApiPerMinute = "ratelimit.api.per_minute" + KeyRateLimitApiBurst = "ratelimit.api.burst" + KeyRateLimitLoginBurst = "ratelimit.login.burst" +) + +// Defaults (used when DB does not have an override) +const ( + DefaultUploadMaxFileSizeBytes int64 = 10 << 30 // 10 GiB (matches MaxMultipartMemory intent) + DefaultUploadMultiMaxFiles = 50 + DefaultUploadMaxHours = 24 * 7 // 7 days + + DefaultRateLimitLoginPerMinute = 5 + DefaultRateLimitLoginBurst = 10 + DefaultRateLimitApiPerMinute = 60 + DefaultRateLimitApiBurst = 30 +) diff --git a/internal/config/model.go b/internal/config/model.go new file mode 100644 index 0000000..f1708f4 --- /dev/null +++ b/internal/config/model.go @@ -0,0 +1,21 @@ +package config + +import "time" + +// ConfigEntry stores runtime-tunable configuration in the database. +// Values are stored as strings but helpers exist for ints/bools/durations. +// +// NOTE: keep keys stable; they are effectively part of the public admin surface. +// +// Example keys: +// - upload.max_file_size_bytes +// - upload.multi.max_files +// - upload.max_hours +// - ratelimit.login.per_minute +// - ratelimit.api.per_minute +// +type ConfigEntry struct { + Key string `gorm:"primaryKey;size:128"` + Value string `gorm:"size:2048"` + UpdatedAt time.Time +} diff --git a/internal/config/repository.go b/internal/config/repository.go new file mode 100644 index 0000000..b836c5e --- /dev/null +++ b/internal/config/repository.go @@ -0,0 +1,40 @@ +package config + +import "gorm.io/gorm" + +type Repository struct { + db *gorm.DB +} + +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Get(key string) (*ConfigEntry, error) { + var e ConfigEntry + if err := r.db.First(&e, "key = ?", key).Error; err != nil { + return nil, err + } + return &e, nil +} + +func (r *Repository) Upsert(key, value string) error { + // Prefer a simple approach that works across sqlite/postgres/mysql. + // Try update first, then insert if nothing updated. + res := r.db.Model(&ConfigEntry{}).Where("key = ?", key).Update("value", value) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + return nil + } + return r.db.Create(&ConfigEntry{Key: key, Value: value}).Error +} + +func (r *Repository) List() ([]ConfigEntry, error) { + var entries []ConfigEntry + if err := r.db.Order("key asc").Find(&entries).Error; err != nil { + return nil, err + } + return entries, nil +} diff --git a/internal/config/service.go b/internal/config/service.go new file mode 100644 index 0000000..60f7515 --- /dev/null +++ b/internal/config/service.go @@ -0,0 +1,125 @@ +package config + +import ( + "errors" + "strconv" + "sync" + "time" + + "gorm.io/gorm" +) + +type Service struct { + repo *Repository + + // Small in-memory cache to avoid hitting the DB on every request. + // Updated on SetString; lazy-filled on first Get. + mu sync.RWMutex + cache map[string]string +} + +func NewService(r *Repository) *Service { + return &Service{repo: r, cache: make(map[string]string)} +} + +func (s *Service) List() ([]ConfigEntry, error) { + entries, err := s.repo.List() + if err != nil { + return nil, err + } + // refresh cache snapshot + s.mu.Lock() + for _, e := range entries { + s.cache[e.Key] = e.Value + } + s.mu.Unlock() + return entries, nil +} + +func (s *Service) SetString(key, value string) error { + if err := s.repo.Upsert(key, value); err != nil { + return err + } + s.mu.Lock() + s.cache[key] = value + s.mu.Unlock() + return nil +} + +func (s *Service) GetStringDefault(key, def string) string { + s.mu.RLock() + v, ok := s.cache[key] + s.mu.RUnlock() + if ok { + if v == "" { + return def + } + return v + } + + e, err := s.repo.Get(key) + if err != nil { + return def + } + + s.mu.Lock() + s.cache[key] = e.Value + s.mu.Unlock() + + if e.Value == "" { + return def + } + return e.Value +} + +func (s *Service) GetIntDefault(key string, def int) int { + v := s.GetStringDefault(key, "") + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} + +func (s *Service) GetInt64Default(key string, def int64) int64 { + v := s.GetStringDefault(key, "") + if v == "" { + return def + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return def + } + return n +} + +func (s *Service) GetBoolDefault(key string, def bool) bool { + v := s.GetStringDefault(key, "") + if v == "" { + return def + } + b, err := strconv.ParseBool(v) + if err != nil { + return def + } + return b +} + +func (s *Service) GetDurationSecondsDefault(key string, def time.Duration) time.Duration { + v := s.GetStringDefault(key, "") + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return time.Duration(n) * time.Second +} + +func IsNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} diff --git a/internal/file/handlers.go b/internal/file/handlers.go index e0c17d7..f10c6f2 100644 --- a/internal/file/handlers.go +++ b/internal/file/handlers.go @@ -1,6 +1,7 @@ package file import ( + "ResendIt/internal/config" "ResendIt/internal/util" "fmt" "net/http" @@ -12,11 +13,19 @@ import ( ) type Handler struct { - service *Service + service *Service + configService ConfigService } -func NewHandler(s *Service) *Handler { - return &Handler{service: s} +// ConfigService is the small interface we need from the config package. +// Keeping it as an interface avoids import cycles and keeps file.Handler easy to test. +type ConfigService interface { + GetIntDefault(key string, def int) int + GetInt64Default(key string, def int64) int64 +} + +func NewHandler(s *Service, cfg ConfigService) *Handler { + return &Handler{service: s, configService: cfg} } func (h *Handler) Upload(c *gin.Context) { @@ -32,6 +41,12 @@ func (h *Handler) Upload(c *gin.Context) { return } + maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) + if file.Size > maxSize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"}) + return + } + f, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) @@ -46,6 +61,10 @@ func (h *Handler) Upload(c *gin.Context) { if err != nil || hours <= 0 { hours = 24 // default } + maxHours := h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours) + if hours > maxHours { + hours = maxHours + } duration := time.Duration(hours) * time.Hour diff --git a/internal/file/upload_multi.go b/internal/file/upload_multi.go index 4729705..751317f 100644 --- a/internal/file/upload_multi.go +++ b/internal/file/upload_multi.go @@ -1,6 +1,7 @@ package file import ( + "ResendIt/internal/config" "net/http" "strconv" "time" @@ -26,11 +27,27 @@ func (h *Handler) UploadMulti(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "missing files"}) return } - if len(files) > 50 { - c.JSON(http.StatusBadRequest, gin.H{"error": "too many files (max 50)"}) + maxFiles := h.configService.GetIntDefault(config.KeyUploadMultiMaxFiles, config.DefaultUploadMultiMaxFiles) + if len(files) > maxFiles { + c.JSON(http.StatusBadRequest, gin.H{"error": "too many files"}) return } + maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) + var total int64 + for _, fh := range files { + if fh.Size > maxSize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"}) + return + } + total += fh.Size + if total > maxSize { + // crude guard against huge bundles; you can add a separate config later. + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "bundle too large"}) + return + } + } + once := c.PostForm("once") == "true" durationStr := c.PostForm("duration") @@ -38,6 +55,10 @@ func (h *Handler) UploadMulti(c *gin.Context) { if err != nil || hours <= 0 { hours = 24 } + maxHours := h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours) + if hours > maxHours { + hours = maxHours + } duration := time.Duration(hours) * time.Hour record, err := h.service.UploadBundle(files, once, duration) diff --git a/internal/web/config.go b/internal/web/config.go new file mode 100644 index 0000000..afed66d --- /dev/null +++ b/internal/web/config.go @@ -0,0 +1,151 @@ +package web + +import ( + "ResendIt/internal/config" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type ConfigPageData struct { + Success bool + Error string + + UploadMaxFileSizeMB int64 + UploadMultiMaxFiles int + UploadMaxHours int + + RateLimitLoginPerMinute int + RateLimitLoginBurst int + RateLimitApiPerMinute int + RateLimitApiBurst int +} + +// ConfigPage renders a modular admin config screen. +func (h *Handler) ConfigPage(c *gin.Context) { + cfg := h.configService + + maxBytes := cfg.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) + data := ConfigPageData{ + Success: c.Query("saved") == "1", + UploadMaxFileSizeMB: maxBytes / (1024 * 1024), + UploadMultiMaxFiles: cfg.GetIntDefault(config.KeyUploadMultiMaxFiles, config.DefaultUploadMultiMaxFiles), + UploadMaxHours: cfg.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours), + RateLimitLoginPerMinute: cfg.GetIntDefault(config.KeyRateLimitLoginPerMinute, config.DefaultRateLimitLoginPerMinute), + RateLimitLoginBurst: cfg.GetIntDefault(config.KeyRateLimitLoginBurst, config.DefaultRateLimitLoginBurst), + RateLimitApiPerMinute: cfg.GetIntDefault(config.KeyRateLimitApiPerMinute, config.DefaultRateLimitApiPerMinute), + RateLimitApiBurst: cfg.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst), + } + + c.HTML(http.StatusOK, "config.html", data) +} + +func (h *Handler) ConfigSave(c *gin.Context) { + cfg := h.configService + + // Parse + validate. + parseInt := func(name string, min, max int) (int, error) { + v := c.PostForm(name) + n, err := strconv.Atoi(v) + if err != nil { + return 0, err + } + if n < min { + n = min + } + if max > 0 && n > max { + n = max + } + return n, nil + } + + parseInt64 := func(name string, min int64, max int64) (int64, error) { + v := c.PostForm(name) + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, err + } + if n < min { + n = min + } + if max > 0 && n > max { + n = max + } + return n, nil + } + + maxMB, err := parseInt64("upload_max_file_size_mb", 1, 1024*1024) + if err != nil { + h.renderConfigError(c, "invalid max file size") + return + } + maxFiles, err := parseInt("upload_multi_max_files", 1, 500) + if err != nil { + h.renderConfigError(c, "invalid max files") + return + } + maxHours, err := parseInt("upload_max_hours", 1, 24*365) + if err != nil { + h.renderConfigError(c, "invalid max hours") + return + } + + // Rate limits: stored, but not applied dynamically yet. + loginPerMin, err := parseInt("ratelimit_login_per_minute", 1, 10000) + if err != nil { + h.renderConfigError(c, "invalid login rate") + return + } + loginBurst, err := parseInt("ratelimit_login_burst", 1, 10000) + if err != nil { + h.renderConfigError(c, "invalid login burst") + return + } + apiPerMin, err := parseInt("ratelimit_api_per_minute", 1, 100000) + if err != nil { + h.renderConfigError(c, "invalid api rate") + return + } + apiBurst, err := parseInt("ratelimit_api_burst", 1, 100000) + if err != nil { + h.renderConfigError(c, "invalid api burst") + return + } + + // Persist. + if err := cfg.SetString(config.KeyUploadMaxFileSizeBytes, strconv.FormatInt(maxMB*1024*1024, 10)); err != nil { + h.renderConfigError(c, err.Error()) + return + } + if err := cfg.SetString(config.KeyUploadMultiMaxFiles, strconv.Itoa(maxFiles)); err != nil { + h.renderConfigError(c, err.Error()) + return + } + if err := cfg.SetString(config.KeyUploadMaxHours, strconv.Itoa(maxHours)); err != nil { + h.renderConfigError(c, err.Error()) + return + } + + _ = cfg.SetString(config.KeyRateLimitLoginPerMinute, strconv.Itoa(loginPerMin)) + _ = cfg.SetString(config.KeyRateLimitLoginBurst, strconv.Itoa(loginBurst)) + _ = cfg.SetString(config.KeyRateLimitApiPerMinute, strconv.Itoa(apiPerMin)) + _ = cfg.SetString(config.KeyRateLimitApiBurst, strconv.Itoa(apiBurst)) + + c.Redirect(http.StatusFound, "/config?saved=1") +} + +func (h *Handler) renderConfigError(c *gin.Context, msg string) { + maxBytes := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) + data := ConfigPageData{ + Error: msg, + UploadMaxFileSizeMB: maxBytes / (1024 * 1024), + UploadMultiMaxFiles: h.configService.GetIntDefault(config.KeyUploadMultiMaxFiles, config.DefaultUploadMultiMaxFiles), + UploadMaxHours: h.configService.GetIntDefault(config.KeyUploadMaxHours, config.DefaultUploadMaxHours), + RateLimitLoginPerMinute: h.configService.GetIntDefault(config.KeyRateLimitLoginPerMinute, config.DefaultRateLimitLoginPerMinute), + RateLimitLoginBurst: h.configService.GetIntDefault(config.KeyRateLimitLoginBurst, config.DefaultRateLimitLoginBurst), + RateLimitApiPerMinute: h.configService.GetIntDefault(config.KeyRateLimitApiPerMinute, config.DefaultRateLimitApiPerMinute), + RateLimitApiBurst: h.configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst), + } + c.HTML(http.StatusBadRequest, "config.html", data) +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 8372eff..098337f 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -9,12 +9,21 @@ import ( ) type Handler struct { - fileService *file.Service + fileService *file.Service + configService ConfigService } -func NewHandler(fileService *file.Service) *Handler { +// ConfigService is the small interface needed by the web layer. +type ConfigService interface { + GetIntDefault(key string, def int) int + GetInt64Default(key string, def int64) int64 + SetString(key, value string) error +} + +func NewHandler(fileService *file.Service, cfg ConfigService) *Handler { return &Handler{ - fileService: fileService, + fileService: fileService, + configService: cfg, } } diff --git a/internal/web/routes.go b/internal/web/routes.go index fe954a6..af119f7 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -20,6 +20,8 @@ func RegisterRoutes(r *gin.Engine, h *Handler, userService *user.Service) { adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService)) adminRoutes.GET("/admin", h.AdminPage) + adminRoutes.GET("/config", h.ConfigPage) + adminRoutes.POST("/config", h.ConfigSave) adminRoutes.GET("/logout", h.Logout) adminRoutes.GET("/change-password", h.ChangePasswordPage) } diff --git a/templates/admin.html b/templates/admin.html index 88caf63..6af4129 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -113,6 +113,7 @@
← BACK_TO_UPLOADER + CONFIG_MODULE LOGOUT_SESSION
diff --git a/templates/config.html b/templates/config.html new file mode 100644 index 0000000..6093861 --- /dev/null +++ b/templates/config.html @@ -0,0 +1,109 @@ + + + + + + Config + + + + + + +

Config

+ + {{if .Success}} +
Saved.
+ {{end}} + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+ +
+

Uploads

+ +
+ +
+ +
Hard limit enforced by the upload endpoints. (This is separate from reverse proxy limits.)
+
+
+ +
+ +
+ +
Limits how many files can be sent to /api/files/upload-multi in one request.
+
+
+ +
+ +
+ +
User-chosen duration is clamped to this maximum.
+
+
+ +
+ +
+

Rate limiting (display only)

+
Right now rate limits are wired at startup; changing these values won’t take effect until the server restarts (unless we refactor it to be dynamic).
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+ +