feat(storage): add S3 backend support and advanced upload limits

- Introduce S3-compatible storage backend support using minio-go.
- Add configuration options for local storage limits, box limits, and rate limiting.
- Implement storage backend selection (local vs S3) for anonymous and registered users.
- Add an `/admin/storage` management interface.
- Update documentation and environment examples with the new configuration variables.
This commit is contained in:
2026-05-31 02:14:10 +03:00
parent 830d2a885c
commit c3558fd353
34 changed files with 2668 additions and 168 deletions

View File

@@ -269,6 +269,104 @@ func TestSignedInDailyCap(t *testing.T) {
}
}
func TestLayeredUploadLimits(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousDailyBoxes = 1
policy.AnonymousActiveBoxes = 10
policy.AnonymousMaxDays = 3
policy.LocalStorageMaxGB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
first := uploadThroughApp(t, app)
if first.BoxID == "" {
t.Fatalf("first upload did not return a box id")
}
secondRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
secondRequest.Header.Set("Accept", "application/json")
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, secondRequest)
if secondResponse.Code != http.StatusTooManyRequests {
t.Fatalf("daily box status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
policy.AnonymousDailyBoxes = 10
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
expiryRequest := multipartUploadRequestWithField(t, "/api/v1/upload", "file", "expiry.txt", "hello", "max_days", "30")
expiryRequest.Header.Set("Accept", "application/json")
expiryResponse := httptest.NewRecorder()
app.Upload(expiryResponse, expiryRequest)
if expiryResponse.Code != http.StatusRequestEntityTooLarge && expiryResponse.Code != http.StatusTooManyRequests {
t.Fatalf("expiry/box status = %d, body = %s", expiryResponse.Code, expiryResponse.Body.String())
}
}
func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
dailyBoxes := 1
maxDays := 1
if err := app.authService.SetUserPolicy(user.ID, services.UserPolicy{DailyBoxes: &dailyBoxes, MaxDays: &maxDays}); err != nil {
t.Fatalf("SetUserPolicy returned error: %v", err)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
first := multipartUploadRequest(t, "/api/v1/upload", "file", "one.txt", "hello")
first.Header.Set("Accept", "application/json")
first.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
firstResponse := httptest.NewRecorder()
app.Upload(firstResponse, first)
if firstResponse.Code != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
}
second := multipartUploadRequest(t, "/api/v1/upload", "file", "two.txt", "hello")
second.Header.Set("Accept", "application/json")
second.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, second)
if secondResponse.Code != http.StatusTooManyRequests {
t.Fatalf("second status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
}
func TestLocalStorageCapRejectsUpload(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 4
policy.AnonymousDailyUploadMB = 8
policy.LocalStorageMaxGB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("storage cap status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
@@ -281,10 +379,11 @@ func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
t.Fatalf("Login returned error: %v", err)
}
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30")
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30&csrf_token=test-csrf")
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
settingsResponse := httptest.NewRecorder()
app.AdminSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther {
@@ -320,9 +419,10 @@ func TestAdminUserQuotaPostChangesEnforcement(t *testing.T) {
t.Fatalf("admin Login returned error: %v", err)
}
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001"))
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001&csrf_token=test-csrf"))
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
quotaRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
quotaRequest.SetPathValue("userID", user.ID)
quotaResponse := httptest.NewRecorder()
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
@@ -19,6 +20,8 @@ type adminPageData struct {
Boxes []adminBoxView
Users []adminUserView
Settings services.UploadPolicySettings
Storage []services.StorageBackendView
UserEdit adminUserEditView
Section string
PageTitle string
LastInviteURL string
@@ -39,15 +42,40 @@ type adminBoxView struct {
}
type adminUserView struct {
ID string
Username string
Email string
Role string
Status string
StorageUsed string
StorageQuota string
DailyUsed string
CreatedAt string
ID string
Username string
Email string
Role string
Status string
StorageUsed string
StorageQuota string
DailyUsed string
StorageBackend string
CreatedAt string
}
type adminUserEditView struct {
ID string
Username string
Email string
Role string
Status string
StorageUsed string
DailyUsed string
EffectiveStorage string
EffectiveDaily string
EffectiveMaxDays int
EffectiveDailyBoxes int
EffectiveActiveBoxes int
EffectiveBackend string
MaxUploadMB string
DailyUploadMB string
StorageQuotaMB string
MaxDays string
DailyBoxes string
ActiveBoxes string
ShortWindowRequests string
StorageBackendID string
}
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
@@ -59,6 +87,10 @@ func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
}
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("admin-login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAdminLogin(w, r, http.StatusTooManyRequests, "Too many admin login attempts.")
return
}
if err := r.ParseForm(); err != nil {
a.renderAdminLogin(w, r, http.StatusBadRequest, "Unable to read login form.")
return
@@ -83,6 +115,9 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
}
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
if !a.validateCSRF(w, r) {
return
}
a.clearUserSessionCookie(w)
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
@@ -176,20 +211,22 @@ func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
for _, user := range users {
storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID)
usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
policy := a.settingsService.EffectivePolicyForUser(settings, user)
quota := "unlimited"
if policy.StorageQuotaSet {
quota = formatMB(policy.StorageQuotaMB)
}
rows = append(rows, adminUserView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
StorageQuota: formatMB(quotaMB),
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
StorageQuota: quota,
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
StorageBackend: policy.StorageBackendID,
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
})
}
a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{
@@ -206,6 +243,45 @@ func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
})
}
func (a *App) AdminEditUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
user, err := a.authService.UserByID(r.PathValue("userID"))
if err != nil {
http.NotFound(w, r)
return
}
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
storage, err := a.storageBackendViews()
if err != nil {
http.Error(w, "unable to load storage", http.StatusInternalServerError)
return
}
edit, err := a.adminUserEdit(user, settings)
if err != nil {
http.Error(w, "unable to load user policy", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_user_edit.html", web.PageData{
Title: "Edit user",
Description: "Edit a Warpbox user.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
UserEdit: edit,
Storage: storage,
Section: "users",
PageTitle: "Edit user",
LastInviteURL: r.URL.Query().Get("invite"),
Error: r.URL.Query().Get("error"),
},
})
}
func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
@@ -215,12 +291,18 @@ func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
storage, err := a.storageBackendViews()
if err != nil {
http.Error(w, "unable to load storage", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{
Title: "Admin settings",
Description: "Manage Warpbox upload policy.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Settings: settings,
Storage: storage,
Section: "settings",
PageTitle: "Settings",
},
@@ -228,18 +310,55 @@ func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
}
func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
return
}
settings := services.UploadPolicySettings{
AnonymousUploadsEnabled: r.FormValue("anonymous_uploads_enabled") == "on",
UsageRetentionDays: parsePositiveInt(r.FormValue("usage_retention_days")),
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
settings.AnonymousUploadsEnabled = r.FormValue("anonymous_uploads_enabled") == "on"
if value := parsePositiveInt(r.FormValue("usage_retention_days")); value > 0 {
settings.UsageRetentionDays = value
}
if value := parsePositiveFloat(r.FormValue("local_storage_max_gb")); value > 0 {
settings.LocalStorageMaxGB = value
}
if value := parsePositiveInt(r.FormValue("anonymous_max_days")); value > 0 {
settings.AnonymousMaxDays = value
}
if value := parsePositiveInt(r.FormValue("user_max_days")); value > 0 {
settings.UserMaxDays = value
}
if value := parsePositiveInt(r.FormValue("anonymous_daily_boxes")); value > 0 {
settings.AnonymousDailyBoxes = value
}
if value := parsePositiveInt(r.FormValue("user_daily_boxes")); value > 0 {
settings.UserDailyBoxes = value
}
if value := parsePositiveInt(r.FormValue("anonymous_active_boxes")); value > 0 {
settings.AnonymousActiveBoxes = value
}
if value := parsePositiveInt(r.FormValue("user_active_boxes")); value > 0 {
settings.UserActiveBoxes = value
}
if value := parsePositiveInt(r.FormValue("short_window_requests")); value > 0 {
settings.ShortWindowRequests = value
}
if value := parsePositiveInt(r.FormValue("short_window_seconds")); value > 0 {
settings.ShortWindowSeconds = value
}
if value := r.FormValue("anonymous_storage_backend"); value != "" {
settings.AnonymousStorageBackend = value
}
if value := r.FormValue("user_storage_backend"); value != "" {
settings.UserStorageBackend = value
}
var err error
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -260,6 +379,14 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "usage retention days must be positive", http.StatusBadRequest)
return
}
if _, err := a.uploadService.Storage().BackendConfig(settings.AnonymousStorageBackend); err != nil {
http.Error(w, "anonymous storage backend not found", http.StatusBadRequest)
return
}
if _, err := a.uploadService.Storage().BackendConfig(settings.UserStorageBackend); err != nil {
http.Error(w, "user storage backend not found", http.StatusBadRequest)
return
}
if err := a.settingsService.UpdateUploadPolicy(settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -267,10 +394,127 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
views, err := a.storageBackendViews()
if err != nil {
http.Error(w, "unable to load storage", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_storage.html", web.PageData{
Title: "Admin storage",
Description: "Manage Warpbox storage backends.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Settings: settings,
Storage: views,
Section: "storage",
PageTitle: "Storage",
Error: r.URL.Query().Get("error"),
},
})
}
func (a *App) AdminCreateS3Storage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
return
}
_, err := a.uploadService.Storage().CreateS3Backend(services.StorageBackendConfig{
Provider: r.FormValue("provider"),
Name: r.FormValue("name"),
Endpoint: r.FormValue("endpoint"),
Region: r.FormValue("region"),
Bucket: r.FormValue("bucket"),
AccessKey: r.FormValue("access_key"),
SecretKey: r.FormValue("secret_key"),
UseSSL: r.FormValue("use_ssl") == "on",
PathStyle: r.FormValue("path_style") == "on",
})
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
return
}
_, err := a.uploadService.Storage().UpdateS3Backend(r.PathValue("backendID"), services.StorageBackendConfig{
Provider: r.FormValue("provider"),
Name: r.FormValue("name"),
Endpoint: r.FormValue("endpoint"),
Region: r.FormValue("region"),
Bucket: r.FormValue("bucket"),
AccessKey: r.FormValue("access_key"),
SecretKey: r.FormValue("secret_key"),
UseSSL: r.FormValue("use_ssl") == "on",
PathStyle: r.FormValue("path_style") == "on",
})
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if _, err := a.uploadService.Storage().TestBackend(r.PathValue("backendID")); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
id := r.PathValue("backendID")
inUse, _ := a.storageBackendInUse(id)
if err := a.uploadService.Storage().DisableBackend(id, inUse); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
id := r.PathValue("backendID")
inUse, _ := a.storageBackendInUse(id)
if err := a.uploadService.Storage().DeleteBackend(id, inUse); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
@@ -291,9 +535,99 @@ func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserPolicy(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
policy := services.UserPolicy{
MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")),
DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")),
StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")),
MaxDays: optionalInt(r.FormValue("max_days")),
DailyBoxes: optionalInt(r.FormValue("daily_boxes")),
ActiveBoxes: optionalInt(r.FormValue("active_boxes")),
ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")),
}
if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
http.Error(w, "storage backend not found", http.StatusBadRequest)
return
}
policy.StorageBackendID = &backendID
}
if err := a.authService.SetUserPolicy(r.PathValue("userID"), policy); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther)
return
}
policy := services.UserPolicy{
MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")),
DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")),
StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")),
MaxDays: optionalInt(r.FormValue("max_days")),
DailyBoxes: optionalInt(r.FormValue("daily_boxes")),
ActiveBoxes: optionalInt(r.FormValue("active_boxes")),
ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")),
}
if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape("storage backend not found"), http.StatusSeeOther)
return
}
policy.StorageBackendID = &backendID
}
if _, err := a.authService.UpdateUserAdminFields(
r.PathValue("userID"),
r.FormValue("username"),
r.FormValue("email"),
r.FormValue("role"),
r.FormValue("status"),
policy,
); err != nil {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
http.Error(w, "storage backend not found", http.StatusBadRequest)
return
}
}
if err := a.authService.SetUserStorageBackend(r.PathValue("userID"), r.FormValue("storage_backend_id")); err != nil {
http.Error(w, "unable to update user storage", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
@@ -310,7 +644,7 @@ func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
}
func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
disabled := r.URL.Query().Get("disabled") != "false"
@@ -323,7 +657,7 @@ func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID)
@@ -331,11 +665,15 @@ func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to create reset link", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("next") == "edit" {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
}
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
@@ -471,6 +809,161 @@ func parsePositiveInt(value string) int {
return parsed
}
func parsePositiveFloat(value string) float64 {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return parsed
}
func optionalMB(value string) *float64 {
if value == "" {
return nil
}
parsed, err := services.ParseMegabytesValue(value)
if err != nil {
return nil
}
return &parsed
}
func optionalMBAllowZero(value string) *float64 {
if value == "" {
return nil
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 {
return nil
}
return &parsed
}
func optionalInt(value string) *int {
if value == "" {
return nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return nil
}
return &parsed
}
func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
}
func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
configs, err := a.uploadService.Storage().ListBackendConfigs()
if err != nil {
return nil, err
}
views := make([]services.StorageBackendView, 0, len(configs))
for _, cfg := range configs {
var usage int64
if backend, err := a.uploadService.Storage().BackendConfig(cfg.ID); err == nil && backend.Enabled {
if concrete, err := a.uploadService.Storage().Backend(cfg.ID); err == nil {
usage, _ = concrete.Usage(context.Background())
}
}
inUse, _ := a.storageBackendInUse(cfg.ID)
views = append(views, services.StorageBackendView{
Config: cfg,
UsageBytes: usage,
UsageLabel: services.FormatMegabytesFromBytes(usage),
InUse: inUse,
})
}
return views, nil
}
func (a *App) adminUserEdit(user services.User, settings services.UploadPolicySettings) (adminUserEditView, error) {
storageUsed, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil {
return adminUserEditView{}, err
}
usage, err := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
if err != nil {
return adminUserEditView{}, err
}
effective := a.settingsService.EffectivePolicyForUser(settings, user)
view := adminUserEditView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
EffectiveDaily: services.FormatMegabytesLabel(effective.DailyUploadMB),
EffectiveMaxDays: effective.MaxDays,
EffectiveDailyBoxes: effective.DailyBoxes,
EffectiveActiveBoxes: effective.ActiveBoxes,
EffectiveBackend: effective.StorageBackendID,
MaxUploadMB: floatPtrString(user.Policy.MaxUploadMB),
DailyUploadMB: floatPtrString(user.Policy.DailyUploadMB),
StorageQuotaMB: floatPtrString(user.Policy.StorageQuotaMB),
MaxDays: intPtrString(user.Policy.MaxDays),
DailyBoxes: intPtrString(user.Policy.DailyBoxes),
ActiveBoxes: intPtrString(user.Policy.ActiveBoxes),
ShortWindowRequests: intPtrString(user.Policy.ShortWindowRequests),
StorageBackendID: stringPtrString(user.Policy.StorageBackendID),
}
if effective.StorageQuotaSet {
view.EffectiveStorage = services.FormatMegabytesLabel(effective.StorageQuotaMB)
} else {
view.EffectiveStorage = "unlimited"
}
return view, nil
}
func (a *App) storageBackendInUse(id string) (bool, error) {
settings, err := a.settingsService.UploadPolicy()
if err != nil {
return false, err
}
if settings.AnonymousStorageBackend == id || settings.UserStorageBackend == id {
return true, nil
}
boxes, err := a.uploadService.ListBoxes(0)
if err != nil {
return false, err
}
for _, box := range boxes {
if a.uploadService.BoxStorageBackendID(box) == id {
return true, nil
}
}
users, err := a.authService.ListUsers()
if err != nil {
return false, err
}
for _, user := range users {
if user.Policy.StorageBackendID != nil && *user.Policy.StorageBackendID == id {
return true, nil
}
}
return false, nil
}
func floatPtrString(value *float64) string {
if value == nil {
return ""
}
return strconv.FormatFloat(*value, 'f', -1, 64)
}
func intPtrString(value *int) string {
if value == nil {
return ""
}
return strconv.Itoa(*value)
}
func stringPtrString(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -16,6 +16,7 @@ type App struct {
uploadService *services.UploadService
authService *services.AuthService
settingsService *services.SettingsService
rateLimiter *rateLimiter
}
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
@@ -26,6 +27,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService,
authService: authService,
settingsService: settingsService,
rateLimiter: newRateLimiter(),
}
}
@@ -33,6 +35,7 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
if data.CurrentUser == nil {
data.CurrentUser = a.currentPublicUser(r)
}
data.CSRFToken = a.csrfToken(w, r)
a.renderer.Render(w, status, page, data)
}
@@ -59,12 +62,22 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", a.AdminDashboard)
mux.HandleFunc("GET /admin/files", a.AdminFiles)
mux.HandleFunc("GET /admin/users", a.AdminUsers)
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("GET /admin/storage", a.AdminStorage)
mux.HandleFunc("POST /admin/storage/s3", a.AdminCreateS3Storage)
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage)
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
mux.HandleFunc("POST /admin/users/{userID}/edit", a.AdminUpdateUser)
mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy)
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)

View File

@@ -33,6 +33,10 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
}
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
return
}
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
return
@@ -55,6 +59,10 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
}
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
return
}
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
return
@@ -75,6 +83,9 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
if !a.validateCSRF(w, r) {
return
}
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
_ = a.authService.Logout(cookie.Value)
}
@@ -126,7 +137,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {

View File

@@ -104,7 +104,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
@@ -119,7 +119,7 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
@@ -135,7 +135,7 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
@@ -156,7 +156,7 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
if !ok || !a.validateCSRF(w, r) {
return
}
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {

View File

@@ -1,8 +1,10 @@
package handlers
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -143,12 +145,15 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
return
}
path := a.uploadService.ThumbnailPath(box, file)
if path == "" {
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if err != nil {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
return
}
http.ServeFile(w, r, path)
defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
@@ -199,31 +204,40 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
}
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
path := a.uploadService.FilePath(box, file)
source, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer source.Close()
stat, err := source.Stat()
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
if err != nil {
http.NotFound(w, r)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType)
if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
}
http.ServeContent(w, r, file.Name, stat.ModTime(), source)
if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else {
if object.Size > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", object.Size))
}
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, object.Body)
}
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
}
}
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
data, err := io.ReadAll(source)
if err != nil {
return bytes.NewReader(nil)
}
return bytes.NewReader(data)
}
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {

View File

@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strconv"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
@@ -61,11 +62,16 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files."
}
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP."
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
}
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
policy := a.settingsService.EffectivePolicyForUser(settings, user)
maxUpload := a.uploadService.MaxUploadSizeLabel()
if policy.MaxUploadMB > 0 {
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
}
return a.uploadService.MaxUploadSizeLabel(), "Daily cap: " + services.FormatMegabytesLabel(settings.UserDailyUploadMB) + " · Storage quota: " + services.FormatMegabytesLabel(quotaMB) + "."
quota := "unlimited"
if policy.StorageQuotaSet {
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
}
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max."
}

View File

@@ -0,0 +1,78 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"net/http"
"sync"
"time"
)
const csrfCookieName = "warpbox_csrf"
type rateLimiter struct {
mu sync.Mutex
records map[string]rateRecord
}
type rateRecord struct {
StartedAt time.Time
Count int
}
func newRateLimiter() *rateLimiter {
return &rateLimiter{records: make(map[string]rateRecord)}
}
func (l *rateLimiter) Allow(key string, limit int, window time.Duration, now time.Time) bool {
if limit <= 0 || window <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
record := l.records[key]
if record.StartedAt.IsZero() || now.Sub(record.StartedAt) >= window {
l.records[key] = rateRecord{StartedAt: now, Count: 1}
return true
}
record.Count++
l.records[key] = record
return record.Count <= limit
}
func (a *App) csrfToken(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
token := randomToken(32)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().Add(12 * time.Hour),
})
return token
}
func (a *App) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
return true
}
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" || r.FormValue("csrf_token") != cookie.Value {
http.Error(w, "invalid form token", http.StatusForbidden)
return false
}
return true
}
func randomToken(byteCount int) string {
data := make([]byte, byteCount)
if _, err := rand.Read(data); err != nil {
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().String()))
}
return base64.RawURLEncoding.EncodeToString(data)
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"errors"
"fmt"
"mime/multipart"
@@ -27,11 +28,17 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return
}
if !isAdminUpload {
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize()))
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize()))
}
parseLimit := uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize())
parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())
if isAdminUpload {
parseLimit = 32 << 20
}
@@ -53,19 +60,29 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
}
if !isAdminUpload {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, files, totalBytes); message != "" {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
maxDays := parseInt(r.FormValue("max_days"))
if maxDays <= 0 {
maxDays = min(7, effectivePolicy.MaxDays)
}
if !isAdminUpload && maxDays > effectivePolicy.MaxDays {
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return
}
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")),
MaxDays: maxDays,
MaxDownloads: parseInt(r.FormValue("max_downloads")),
Password: r.FormValue("password"),
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
OwnerID: ownerID,
CollectionID: collectionID,
SkipSizeLimit: isAdminUpload,
CreatorIP: uploadClientIP(r),
StorageBackendID: effectivePolicy.StorageBackendID,
})
if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
@@ -73,7 +90,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes); err != nil {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
}
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
@@ -92,25 +109,40 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, files []*multipart.FileHeader, totalBytes int64) (int, string) {
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
if len(files) == 0 {
return 0, ""
}
now := time.Now().UTC()
if !loggedIn {
anonymousMaxBytes := services.MegabytesToBytes(settings.AnonymousMaxUploadMB)
if policy.MaxUploadMB > 0 {
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, file := range files {
if file.Size > anonymousMaxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds anonymous upload size limit"
if file.Size > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
}
}
}
if !loggedIn {
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.AnonymousDailyUploadMB) {
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
}
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "anonymous daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "anonymous active box limit reached"
}
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message
}
return 0, ""
}
@@ -118,42 +150,86 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.UserDailyUploadMB) {
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "daily upload limit reached"
}
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil {
return http.StatusInternalServerError, "storage quota could not be checked"
}
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
if activeStorage+totalBytes > services.MegabytesToBytes(quotaMB) {
if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
return http.StatusRequestEntityTooLarge, "storage quota reached"
}
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message
}
return 0, ""
}
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64) error {
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
now := time.Now().UTC()
if loggedIn {
return a.settingsService.AddUsage("user", user.ID, totalBytes, now)
return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
}
return a.settingsService.AddUsage("ip", uploadClientIP(r), totalBytes, now)
return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
}
func uploadParseLimit(settings services.UploadPolicySettings, loggedIn bool, fallback int64) int64 {
func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
if loggedIn {
return a.settingsService.EffectivePolicyForUser(settings, user)
}
return a.settingsService.EffectivePolicyForAnonymous(settings)
}
func (a *App) checkStorageBackendCapacity(backendID string, settings services.UploadPolicySettings, totalBytes int64) (int, string) {
if backendID != services.StorageBackendLocal {
return 0, ""
}
backend, err := a.uploadService.Storage().Backend(services.StorageBackendLocal)
if err != nil {
return http.StatusInternalServerError, "storage backend could not be checked"
}
used, err := backend.Usage(context.Background())
if err != nil {
return http.StatusInternalServerError, "storage backend usage could not be checked"
}
if used+totalBytes > services.GigabytesToBytes(settings.LocalStorageMaxGB) {
return http.StatusRequestEntityTooLarge, "local storage limit reached"
}
return 0, ""
}
func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
if loggedIn && policy.MaxUploadMB <= 0 {
return fallback * 8
}
return services.MegabytesToBytes(settings.AnonymousMaxUploadMB) * 8
if policy.MaxUploadMB > 0 {
return services.MegabytesToBytes(policy.MaxUploadMB) * 8
}
return fallback * 8
}
func uploadClientIP(r *http.Request) string {
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
}
func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
if loggedIn {
return "user:" + user.ID
}
return "ip:" + uploadClientIP(r)
}
func totalUploadBytes(files []*multipart.FileHeader) int64 {
var total int64
for _, file := range files {

View File

@@ -255,6 +255,29 @@ func multipartUploadRequest(t *testing.T, path, field, filename, body string) *h
return request
}
func multipartUploadRequestWithField(t *testing.T, path, field, filename, body, extraName, extraValue string) *http.Request {
t.Helper()
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile returned error: %v", err)
}
if _, err := part.Write([]byte(body)); err != nil {
t.Fatalf("part.Write returned error: %v", err)
}
if err := writer.WriteField(extraName, extraValue); err != nil {
t.Fatalf("WriteField returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, path, &payload)
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func tokenFromURL(t *testing.T, value string) string {
t.Helper()
parts := strings.Split(strings.TrimRight(value, "/"), "/")