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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
78
backend/libs/handlers/security.go
Normal file
78
backend/libs/handlers/security.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, "/"), "/")
|
||||
|
||||
Reference in New Issue
Block a user