From 1513030c2ac8b9511a3dec557b977f2d3c14eb4c Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sun, 31 May 2026 19:52:46 +0300 Subject: [PATCH] feat(admin): implement provider-specific storage configuration pages Refactor the admin storage backend creation and editing flows to use provider-specific pages (e.g., `/admin/storage/new/sftp`) instead of a single generic form. This ensures only relevant fields are rendered for each storage provider (such as SFTP, S3, or WebDAV). Additionally: - Prevent mutation of the storage provider type during backend edits. - Add comprehensive unit tests for provider-specific rendering, edit validation, and CSRF/admin route protection. --- backend/libs/handlers/accounts_test.go | 198 +++++++ backend/libs/handlers/admin.go | 482 ++++++++++++++++-- backend/libs/handlers/app.go | 19 +- backend/libs/jobs/cleanup.go | 4 + backend/libs/jobs/thumbnails.go | 16 +- backend/libs/services/storage.go | 140 ++++- backend/libs/services/storage_speed.go | 424 +++++++++++++++ backend/libs/services/upload_test.go | 151 ++++++ backend/static/css/60-storage.css | 262 +++++++++- backend/static/js/20-storage-admin.js | 214 ++++---- backend/templates/pages/admin_storage.html | 165 +----- .../templates/pages/admin_storage_form.html | 117 +++++ .../templates/pages/admin_storage_new.html | 50 ++ .../templates/pages/admin_storage_tests.html | 144 ++++++ 14 files changed, 2031 insertions(+), 355 deletions(-) create mode 100644 backend/libs/services/storage_speed.go create mode 100644 backend/templates/pages/admin_storage_form.html create mode 100644 backend/templates/pages/admin_storage_new.html create mode 100644 backend/templates/pages/admin_storage_tests.html diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index dc2c949..b4b3f9c 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -613,6 +614,190 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) { } } +func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + adminToken := createAdminSession(t, app) + + request := httptest.NewRequest(http.MethodGet, "/admin/storage/new/sftp", nil) + request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + response := httptest.NewRecorder() + app.AdminNewStorageProvider(response, request) + if response.Code != http.StatusOK { + t.Fatalf("AdminNewStorageProvider status = %d, body = %s", response.Code, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, "Private key") || !strings.Contains(body, "SSH host key") { + t.Fatalf("sftp page did not render sftp fields: %s", body) + } + for _, unwanted := range []string{"Bucket display name", "WebDAV URL", "Share", "Access key"} { + if strings.Contains(body, unwanted) { + t.Fatalf("sftp page rendered irrelevant field %q: %s", unwanted, body) + } + } + + cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{ + Provider: services.StorageProviderSFTP, + Name: "NAS", + Host: "files.example.test", + Username: "warpbox", + Password: "secret", + }) + if err != nil { + t.Fatalf("CreateBackend returned error: %v", err) + } + editRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/"+cfg.ID+"/edit", nil) + editRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + editRequest.SetPathValue("backendID", cfg.ID) + editResponse := httptest.NewRecorder() + app.AdminEditStorageForm(editResponse, editRequest) + if editResponse.Code != http.StatusOK { + t.Fatalf("AdminEditStorageForm status = %d, body = %s", editResponse.Code, editResponse.Body.String()) + } + editBody := editResponse.Body.String() + if !strings.Contains(editBody, "Immutable provider") || strings.Contains(editBody, "Bucket display name") || strings.Contains(editBody, "WebDAV URL") { + t.Fatalf("edit page did not stay provider-specific: %s", editBody) + } +} + +func TestAdminStorageEditRejectsProviderMutation(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + adminToken := createAdminSession(t, app) + cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{ + Provider: services.StorageProviderSFTP, + Name: "NAS", + Host: "files.example.test", + Username: "warpbox", + Password: "secret", + }) + if err != nil { + t.Fatalf("CreateBackend returned error: %v", err) + } + + form := strings.NewReader("provider=s3&name=Changed&endpoint=https://s3.example.test&bucket=bucket&access_key=access&secret_key=secret&use_ssl=on&csrf_token=test-csrf") + request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/edit", form) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + request.SetPathValue("backendID", cfg.ID) + response := httptest.NewRecorder() + app.AdminEditStorage(response, request) + if response.Code != http.StatusSeeOther { + t.Fatalf("AdminEditStorage status = %d, body = %s", response.Code, response.Body.String()) + } + stored, err := app.uploadService.Storage().BackendConfig(cfg.ID) + if err != nil { + t.Fatalf("BackendConfig returned error: %v", err) + } + if stored.Provider != services.StorageProviderSFTP || stored.Type != services.StorageBackendSFTP || stored.Name != "NAS" { + t.Fatalf("storage backend mutated despite rejected provider change: %+v", stored) + } +} + +func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + unauthorized := httptest.NewRecorder() + app.AdminRunStorageCleanup(unauthorized, httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil)) + if unauthorized.Code != http.StatusSeeOther { + t.Fatalf("unauthorized cleanup status = %d", unauthorized.Code) + } + + adminToken := createAdminSession(t, app) + missingCSRFRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil) + missingCSRFRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + missingCSRFResponse := httptest.NewRecorder() + app.AdminRunStorageCleanup(missingCSRFResponse, missingCSRFRequest) + if missingCSRFResponse.Code != http.StatusForbidden { + t.Fatalf("missing csrf cleanup status = %d", missingCSRFResponse.Code) + } + + request := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", strings.NewReader("csrf_token=test-csrf")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + response := httptest.NewRecorder() + app.AdminRunStorageCleanup(response, request) + if response.Code != http.StatusSeeOther { + t.Fatalf("authorized cleanup status = %d, body = %s", response.Code, response.Body.String()) + } +} + +func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + adminToken := createAdminSession(t, app) + if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil { + t.Fatalf("TestBackend returned error: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/admin/storage/local/speed-test", strings.NewReader("mode=custom&custom_file_count=2&custom_file_size_mb=0.001&csrf_token=test-csrf")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"}) + request.SetPathValue("backendID", services.StorageBackendLocal) + response := httptest.NewRecorder() + app.AdminStartStorageSpeedTest(response, request) + if response.Code != http.StatusSeeOther { + t.Fatalf("AdminStartStorageSpeedTest status = %d, body = %s", response.Code, response.Body.String()) + } + tests, err := app.uploadService.Storage().ListSpeedTests(services.StorageBackendLocal, 10) + if err != nil { + t.Fatalf("ListSpeedTests returned error: %v", err) + } + if len(tests) != 1 { + t.Fatalf("speed tests len = %d, want 1", len(tests)) + } + if tests[0].Mode != services.StorageSpeedModeCustom || tests[0].CustomFileCount != 2 || tests[0].CustomFileSizeMB != 0.001 { + t.Fatalf("custom speed test options were not stored: %+v", tests[0]) + } + location := response.Header().Get("Location") + if !strings.Contains(location, "/admin/storage/local/tests") { + t.Fatalf("speed test redirect location = %q", location) + } +} + +func TestAdminStorageTestingPageRendersHistory(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + adminToken := createAdminSession(t, app) + if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil { + t.Fatalf("TestBackend returned error: %v", err) + } + test, err := app.uploadService.Storage().StartSpeedTest(services.StorageBackendLocal, services.StorageSpeedModeSmall) + if err != nil { + t.Fatalf("StartSpeedTest returned error: %v", err) + } + app.uploadService.Storage().RunSpeedTest(context.Background(), test.ID) + + request := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests", nil) + request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + request.SetPathValue("backendID", services.StorageBackendLocal) + response := httptest.NewRecorder() + app.AdminStorageTests(response, request) + if response.Code != http.StatusOK { + t.Fatalf("AdminStorageTests status = %d, body = %s", response.Code, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, "New Test") || !strings.Contains(body, "Many small files") || strings.Contains(body, "storage-test-menu") { + t.Fatalf("testing page missing expected page-based controls: %s", body) + } + + jsonRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests.json", nil) + jsonRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) + jsonRequest.SetPathValue("backendID", services.StorageBackendLocal) + jsonResponse := httptest.NewRecorder() + app.AdminStorageTestsJSON(jsonResponse, jsonRequest) + if jsonResponse.Code != http.StatusOK { + t.Fatalf("AdminStorageTestsJSON status = %d, body = %s", jsonResponse.Code, jsonResponse.Body.String()) + } + if !strings.Contains(jsonResponse.Body.String(), `"progress":100`) || !strings.Contains(jsonResponse.Body.String(), `"stage":"complete"`) { + t.Fatalf("tests json missing progress fields: %s", jsonResponse.Body.String()) + } +} + func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult { t.Helper() user, err := app.authService.UserByID(userID) @@ -638,6 +823,19 @@ func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.Up return payload } +func createAdminSession(t *testing.T, app *App) string { + t.Helper() + _, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123") + if err != nil && !strings.Contains(err.Error(), "registration is closed") { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + _, token, err := app.authService.Login("admin@example.test", "password123") + if err != nil { + t.Fatalf("Login returned error: %v", err) + } + return token +} + func testPolicy(t *testing.T, app *App) services.UploadPolicySettings { t.Helper() policy, err := app.settingsService.UploadPolicy() diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index ae613c2..0725585 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -4,11 +4,15 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "fmt" "net/http" "net/url" + "path" "strconv" "time" + "warpbox.dev/backend/libs/jobs" "warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/web" ) @@ -22,12 +26,56 @@ type adminPageData struct { Settings services.UploadPolicySettings Storage []services.StorageBackendView UserEdit adminUserEditView + StorageForm adminStorageFormView + StorageTest adminStorageTestView + StorageTypes []adminStorageProviderView Section string PageTitle string LastInviteURL string + Notice string Error string } +type adminStorageFormView struct { + Mode string + Provider string + ProviderLabel string + Action string + BackHref string + Config services.StorageBackendConfig +} + +type adminStorageTestView struct { + Config services.StorageBackendConfig + UsageLabel string + Tests []services.StorageSpeedTest + CanRun bool +} + +type adminStorageSpeedTestJSON struct { + ID string `json:"id"` + Mode string `json:"mode"` + ModeLabel string `json:"modeLabel"` + Status string `json:"status"` + Stage string `json:"stage"` + Progress int `json:"progress"` + CustomLabel string `json:"customLabel,omitempty"` + StartedLabel string `json:"startedLabel"` + FinishedLabel string `json:"finishedLabel"` + Files int `json:"files"` + SizeLabel string `json:"sizeLabel"` + WriteSpeed string `json:"writeSpeed"` + ReadSpeed string `json:"readSpeed"` + Error string `json:"error,omitempty"` +} + +type adminStorageProviderView struct { + Provider string + Label string + Description string + Icon string +} + type adminBoxView struct { ID string Owner string @@ -417,44 +465,167 @@ func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) { Storage: views, Section: "storage", PageTitle: "Storage", + Notice: r.URL.Query().Get("notice"), Error: r.URL.Query().Get("error"), }, }) } -func (a *App) AdminCreateS3Storage(w http.ResponseWriter, r *http.Request) { +func (a *App) AdminNewStorage(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + a.renderPage(w, r, http.StatusOK, "admin_storage_new.html", web.PageData{ + Title: "Add storage", + Description: "Choose a Warpbox storage provider.", + CurrentUser: a.currentPublicUser(r), + Data: adminPageData{ + Section: "storage", + PageTitle: "Add storage", + StorageTypes: adminStorageProviderOptions(), + Error: r.URL.Query().Get("error"), + }, + }) +} + +func (a *App) AdminNewStorageProvider(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + rawProvider := adminStorageProviderFromRequest(r) + if !validAdminStorageProvider(rawProvider) { + http.NotFound(w, r) + return + } + provider := normalizeAdminStorageProvider(rawProvider) + a.renderStorageForm(w, r, http.StatusOK, adminStorageFormView{ + Mode: "create", + Provider: provider, + ProviderLabel: adminStorageProviderLabel(provider), + Action: "/admin/storage/new/" + provider, + BackHref: "/admin/storage/new", + Config: services.StorageBackendConfig{ + Provider: provider, + Type: adminStorageTypeForProvider(provider), + UseSSL: true, + }, + }, r.URL.Query().Get("error")) +} + +func (a *App) AdminEditStorageForm(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID")) + if err != nil || cfg.ID == services.StorageBackendLocal { + http.NotFound(w, r) + return + } + provider := normalizeAdminStorageProvider(cfg.Provider) + a.renderStorageForm(w, r, http.StatusOK, adminStorageFormView{ + Mode: "edit", + Provider: provider, + ProviderLabel: adminStorageProviderLabel(provider), + Action: "/admin/storage/" + cfg.ID + "/edit", + BackHref: "/admin/storage", + Config: cfg, + }, r.URL.Query().Get("error")) +} + +func (a *App) AdminStorageTests(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID")) + if err != nil { + http.NotFound(w, r) + return + } + tests, err := a.uploadService.Storage().ListSpeedTests(cfg.ID, 100) + if err != nil { + http.Error(w, "unable to load storage tests", http.StatusInternalServerError) + return + } + var usage int64 + if cfg.Enabled { + if backend, err := a.uploadService.Storage().Backend(cfg.ID); err == nil { + usage, _ = backend.Usage(context.Background()) + } + } + a.renderPage(w, r, http.StatusOK, "admin_storage_tests.html", web.PageData{ + Title: cfg.Name + " tests", + Description: "Storage speed-test history.", + CurrentUser: a.currentPublicUser(r), + Data: adminPageData{ + Section: "storage", + PageTitle: cfg.Name + " tests", + StorageTest: adminStorageTestView{ + Config: cfg, + UsageLabel: services.FormatMegabytesFromBytes(usage), + Tests: tests, + CanRun: cfg.Enabled && cfg.LastTestSuccess, + }, + Notice: r.URL.Query().Get("notice"), + Error: r.URL.Query().Get("error"), + }, + }) +} + +func (a *App) AdminStorageTestsJSON(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID")) + if err != nil { + http.NotFound(w, r) + return + } + tests, err := a.uploadService.Storage().ListSpeedTests(cfg.ID, 100) + if err != nil { + http.Error(w, "unable to load storage tests", http.StatusInternalServerError) + return + } + payload := struct { + Tests []adminStorageSpeedTestJSON `json:"tests"` + }{Tests: adminStorageSpeedTestsJSON(tests)} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) +} + +func (a *App) renderStorageForm(w http.ResponseWriter, r *http.Request, status int, form adminStorageFormView, message string) { + a.renderPage(w, r, status, "admin_storage_form.html", web.PageData{ + Title: form.ProviderLabel + " storage", + Description: "Configure Warpbox storage.", + CurrentUser: a.currentPublicUser(r), + Data: adminPageData{ + Section: "storage", + PageTitle: form.ProviderLabel + " storage", + StorageForm: form, + Error: message, + }, + }) +} + +func (a *App) AdminCreateStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } + rawProvider := adminStorageProviderFromRequest(r) + if !validAdminStorageProvider(rawProvider) { + http.NotFound(w, r) + return + } + provider := normalizeAdminStorageProvider(rawProvider) if err := r.ParseForm(); err != nil { - http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) + http.Redirect(w, r, "/admin/storage/new/"+provider, 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", - Host: r.FormValue("host"), - Port: parsePositiveInt(r.FormValue("port")), - Username: r.FormValue("username"), - Password: r.FormValue("password"), - PrivateKey: r.FormValue("private_key"), - HostKey: r.FormValue("host_key"), - RemotePath: r.FormValue("remote_path"), - Share: r.FormValue("share"), - Domain: r.FormValue("domain"), - }) + _, err := a.uploadService.Storage().CreateBackend(a.storageConfigFromForm(r, provider)) if err != nil { - http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + http.Redirect(w, r, "/admin/storage/new/"+provider+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) + http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend added."), http.StatusSeeOther) } func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) { @@ -465,42 +636,50 @@ func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) { 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", - Host: r.FormValue("host"), - Port: parsePositiveInt(r.FormValue("port")), - Username: r.FormValue("username"), - Password: r.FormValue("password"), - PrivateKey: r.FormValue("private_key"), - HostKey: r.FormValue("host_key"), - RemotePath: r.FormValue("remote_path"), - Share: r.FormValue("share"), - Domain: r.FormValue("domain"), - }) + provider := normalizeAdminStorageProvider(r.FormValue("provider")) + _, err := a.uploadService.Storage().UpdateBackend(r.PathValue("backendID"), a.storageConfigFromForm(r, provider)) if err != nil { - http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) + http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend updated."), http.StatusSeeOther) } func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } + next := "/admin/storage" + if r.FormValue("next") == "tests" { + next = "/admin/storage/" + r.PathValue("backendID") + "/tests" + } if _, err := a.uploadService.Storage().TestBackend(r.PathValue("backendID")); err != nil { - http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + http.Redirect(w, r, next+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } - http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) + http.Redirect(w, r, next+"?notice="+url.QueryEscape("Storage connection test passed."), http.StatusSeeOther) +} + +func (a *App) AdminStartStorageSpeedTest(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?error="+url.QueryEscape("Unable to read speed test form."), http.StatusSeeOther) + return + } + options := services.StorageSpeedTestOptions{ + Mode: r.FormValue("mode"), + CustomFileCount: parsePositiveInt(r.FormValue("custom_file_count")), + CustomFileSizeMB: parsePositiveFloat(r.FormValue("custom_file_size_mb")), + } + test, err := a.uploadService.Storage().StartSpeedTestWithOptions(r.PathValue("backendID"), options) + if err != nil { + http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + go a.uploadService.Storage().RunSpeedTest(context.Background(), test.ID) + http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther) } func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) { @@ -529,6 +708,56 @@ func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/storage", http.StatusSeeOther) } +func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + cleaned, err := jobs.RunCleanupNow(a.uploadService, a.logger) + if err != nil { + http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(fmt.Sprintf("Cleanup finished. Removed %d unavailable boxes.", cleaned)), http.StatusSeeOther) +} + +func (a *App) AdminRunStorageThumbnails(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + result, err := jobs.RunThumbnailsNow(a.uploadService, a.logger) + if err != nil { + http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + message := fmt.Sprintf("Thumbnail pass finished. Scanned %d files, generated %d, failed %d.", result.Scanned, result.Generated, result.Failed) + http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther) +} + +func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + configs, err := a.uploadService.Storage().ListBackendConfigs() + if err != nil { + http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + passed := 0 + failed := 0 + for _, cfg := range configs { + if !cfg.Enabled { + continue + } + if _, err := a.uploadService.Storage().TestBackend(cfg.ID); err != nil { + failed++ + continue + } + passed++ + } + message := fmt.Sprintf("Storage verification finished. %d passed, %d failed.", passed, failed) + http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther) +} + func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return @@ -872,6 +1101,152 @@ func formatMB(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) + " MB" } +func adminStorageSpeedTestsJSON(tests []services.StorageSpeedTest) []adminStorageSpeedTestJSON { + rows := make([]adminStorageSpeedTestJSON, 0, len(tests)) + for _, test := range tests { + rows = append(rows, adminStorageSpeedTestJSON{ + ID: test.ID, + Mode: test.Mode, + ModeLabel: test.ModeLabel(), + Status: test.Status, + Stage: test.Stage, + Progress: test.ProgressPercent, + CustomLabel: storageSpeedCustomLabel(test), + StartedLabel: test.StartedLabel(), + FinishedLabel: test.FinishedLabel(), + Files: test.FilesWritten, + SizeLabel: test.TotalSizeLabel(), + WriteSpeed: test.WriteSpeedLabel(), + ReadSpeed: test.ReadSpeedLabel(), + Error: test.Error, + }) + } + return rows +} + +func storageSpeedCustomLabel(test services.StorageSpeedTest) string { + if test.Mode != services.StorageSpeedModeCustom { + return "" + } + return fmt.Sprintf("%d files × %s each", test.CustomFileCount, services.FormatMegabytesLabel(test.CustomFileSizeMB)) +} + +func (a *App) storageConfigFromForm(r *http.Request, provider string) services.StorageBackendConfig { + cfg := services.StorageBackendConfig{ + Provider: provider, + Name: r.FormValue("name"), + } + switch provider { + case services.StorageProviderSFTP: + cfg.Host = r.FormValue("host") + cfg.Port = parsePositiveInt(r.FormValue("port")) + cfg.Username = r.FormValue("username") + cfg.Password = r.FormValue("password") + cfg.PrivateKey = r.FormValue("private_key") + cfg.HostKey = r.FormValue("host_key") + cfg.RemotePath = r.FormValue("remote_path") + case services.StorageProviderSMB: + cfg.Host = r.FormValue("host") + cfg.Port = parsePositiveInt(r.FormValue("port")) + cfg.Share = r.FormValue("share") + cfg.Domain = r.FormValue("domain") + cfg.Username = r.FormValue("username") + cfg.Password = r.FormValue("password") + cfg.RemotePath = r.FormValue("remote_path") + case services.StorageProviderWebDAV: + cfg.Endpoint = r.FormValue("endpoint") + cfg.Username = r.FormValue("username") + cfg.Password = r.FormValue("password") + cfg.RemotePath = r.FormValue("remote_path") + case services.StorageProviderContabo: + cfg.Endpoint = r.FormValue("endpoint") + cfg.Region = r.FormValue("region") + cfg.Bucket = r.FormValue("bucket") + cfg.AccessKey = r.FormValue("access_key") + cfg.SecretKey = r.FormValue("secret_key") + cfg.UseSSL = true + cfg.PathStyle = true + default: + cfg.Endpoint = r.FormValue("endpoint") + cfg.Region = r.FormValue("region") + cfg.Bucket = r.FormValue("bucket") + cfg.AccessKey = r.FormValue("access_key") + cfg.SecretKey = r.FormValue("secret_key") + cfg.UseSSL = r.FormValue("use_ssl") == "on" + cfg.PathStyle = r.FormValue("path_style") == "on" + } + return cfg +} + +func adminStorageProviderOptions() []adminStorageProviderView { + return []adminStorageProviderView{ + {Provider: services.StorageProviderS3, Label: "S3 Bucket", Description: "Generic S3-compatible object storage.", Icon: "cloud"}, + {Provider: services.StorageProviderContabo, Label: "Contabo Object Storage", Description: "Contabo COS with TLS and path-style lookup locked on.", Icon: "cloud"}, + {Provider: services.StorageProviderSFTP, Label: "SFTP", Description: "SSH file transfer to a server or NAS.", Icon: "database"}, + {Provider: services.StorageProviderSMB, Label: "Samba / SMB", Description: "Windows share or network attached storage.", Icon: "folder"}, + {Provider: services.StorageProviderWebDAV, Label: "WebDAV", Description: "Nextcloud, ownCloud, or any WebDAV endpoint.", Icon: "sync"}, + } +} + +func normalizeAdminStorageProvider(provider string) string { + switch provider { + case services.StorageProviderContabo: + return services.StorageProviderContabo + case services.StorageProviderSFTP: + return services.StorageProviderSFTP + case services.StorageProviderSMB: + return services.StorageProviderSMB + case services.StorageProviderWebDAV: + return services.StorageProviderWebDAV + default: + return services.StorageProviderS3 + } +} + +func adminStorageProviderFromRequest(r *http.Request) string { + if provider := r.PathValue("provider"); provider != "" { + return provider + } + return path.Base(r.URL.Path) +} + +func validAdminStorageProvider(provider string) bool { + switch provider { + case services.StorageProviderS3, services.StorageProviderContabo, services.StorageProviderSFTP, services.StorageProviderSMB, services.StorageProviderWebDAV: + return true + default: + return false + } +} + +func adminStorageProviderLabel(provider string) string { + switch normalizeAdminStorageProvider(provider) { + case services.StorageProviderContabo: + return "Contabo Object Storage" + case services.StorageProviderSFTP: + return "SFTP" + case services.StorageProviderSMB: + return "Samba / SMB" + case services.StorageProviderWebDAV: + return "WebDAV" + default: + return "S3 Bucket" + } +} + +func adminStorageTypeForProvider(provider string) string { + switch normalizeAdminStorageProvider(provider) { + case services.StorageProviderSFTP: + return services.StorageBackendSFTP + case services.StorageProviderSMB: + return services.StorageBackendSMB + case services.StorageProviderWebDAV: + return services.StorageBackendWebDAV + default: + return services.StorageBackendS3 + } +} + func (a *App) storageBackendViews() ([]services.StorageBackendView, error) { configs, err := a.uploadService.Storage().ListBackendConfigs() if err != nil { @@ -886,11 +1261,14 @@ func (a *App) storageBackendViews() ([]services.StorageBackendView, error) { } } inUse, _ := a.storageBackendInUse(cfg.ID) + speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25) views = append(views, services.StorageBackendView{ - Config: cfg, - UsageBytes: usage, - UsageLabel: services.FormatMegabytesFromBytes(usage), - InUse: inUse, + Config: cfg, + UsageBytes: usage, + UsageLabel: services.FormatMegabytesFromBytes(usage), + InUse: inUse, + SpeedTests: speedTests, + CanSpeedTest: cfg.LastTestSuccess, }) } return views, nil diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index a7774a4..66abe01 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -68,11 +68,28 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { 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("GET /admin/storage/new", a.AdminNewStorage) + mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider) + mux.HandleFunc("GET /admin/storage/new/contabo", a.AdminNewStorageProvider) + mux.HandleFunc("GET /admin/storage/new/sftp", a.AdminNewStorageProvider) + mux.HandleFunc("GET /admin/storage/new/smb", a.AdminNewStorageProvider) + mux.HandleFunc("GET /admin/storage/new/webdav", a.AdminNewStorageProvider) + mux.HandleFunc("POST /admin/storage/new/s3", a.AdminCreateStorage) + mux.HandleFunc("POST /admin/storage/new/contabo", a.AdminCreateStorage) + mux.HandleFunc("POST /admin/storage/new/sftp", a.AdminCreateStorage) + mux.HandleFunc("POST /admin/storage/new/smb", a.AdminCreateStorage) + mux.HandleFunc("POST /admin/storage/new/webdav", a.AdminCreateStorage) + mux.HandleFunc("GET /admin/storage/{backendID}/edit", a.AdminEditStorageForm) + mux.HandleFunc("GET /admin/storage/{backendID}/tests", a.AdminStorageTests) + mux.HandleFunc("GET /admin/storage/{backendID}/tests.json", a.AdminStorageTestsJSON) mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage) mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage) + mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest) mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage) mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage) + mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup) + mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails) + mux.HandleFunc("POST /admin/storage/jobs/verify", a.AdminVerifyStorageBackends) 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) diff --git a/backend/libs/jobs/cleanup.go b/backend/libs/jobs/cleanup.go index 445e181..07c1aed 100644 --- a/backend/libs/jobs/cleanup.go +++ b/backend/libs/jobs/cleanup.go @@ -26,6 +26,10 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic } } +func RunCleanupNow(uploadService *services.UploadService, logger *slog.Logger) (int, error) { + return cleanupUnavailableBoxes(uploadService, logger) +} + func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) { boxes, err := uploadService.ListBoxes(0) if err != nil { diff --git a/backend/libs/jobs/thumbnails.go b/backend/libs/jobs/thumbnails.go index c6d547c..c2ef8c8 100644 --- a/backend/libs/jobs/thumbnails.go +++ b/backend/libs/jobs/thumbnails.go @@ -20,7 +20,7 @@ import ( "warpbox.dev/backend/libs/services" ) -type thumbnailJobResult struct { +type ThumbnailJobResult struct { Scanned int Generated int Failed int @@ -63,13 +63,17 @@ func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *ser } } -func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (thumbnailJobResult, error) { +func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) { + return generateMissingThumbnails(uploadService, logger) +} + +func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) { boxes, err := uploadService.ListBoxes(0) if err != nil { - return thumbnailJobResult{}, err + return ThumbnailJobResult{}, err } - var result thumbnailJobResult + var result ThumbnailJobResult now := time.Now().UTC() for _, box := range boxes { if !box.ExpiresAt.After(now) { @@ -88,8 +92,8 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl return result, nil } -func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (thumbnailJobResult, error) { - var result thumbnailJobResult +func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) { + var result ThumbnailJobResult if !box.ExpiresAt.After(time.Now().UTC()) { return result, nil } diff --git a/backend/libs/services/storage.go b/backend/libs/services/storage.go index ebd34a6..67cf385 100644 --- a/backend/libs/services/storage.go +++ b/backend/libs/services/storage.go @@ -16,6 +16,7 @@ import ( ) var storageBackendsBucket = []byte("storage_backends") +var storageBackendTestStatusBucket = []byte("storage_backend_test_status") const ( StorageBackendLocal = "local" @@ -81,10 +82,12 @@ type StorageBackendConfig struct { } type StorageBackendView struct { - Config StorageBackendConfig - UsageBytes int64 - UsageLabel string - InUse bool + Config StorageBackendConfig + UsageBytes int64 + UsageLabel string + InUse bool + SpeedTests []StorageSpeedTest + CanSpeedTest bool } type StorageService struct { @@ -99,7 +102,13 @@ func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) { } service := &StorageService{db: db, localFilesDir: filesDir} err := db.Update(func(tx *bbolt.Tx) error { - _, err := tx.CreateBucketIfNotExists(storageBackendsBucket) + if _, err := tx.CreateBucketIfNotExists(storageBackendsBucket); err != nil { + return err + } + if _, err := tx.CreateBucketIfNotExists(storageBackendTestStatusBucket); err != nil { + return err + } + _, err := tx.CreateBucketIfNotExists(storageSpeedTestsBucket) return err }) if err != nil { @@ -126,7 +135,9 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) { func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) { id = strings.TrimSpace(id) if id == "" || id == StorageBackendLocal { - return s.localConfig(), nil + cfg := s.localConfig() + s.applyStoredTestStatus(&cfg) + return cfg, nil } var cfg StorageBackendConfig err := s.db.View(func(tx *bbolt.Tx) error { @@ -167,18 +178,13 @@ func (s *StorageService) ListBackendConfigs() ([]StorageBackendConfig, error) { } func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBackendConfig, error) { + return s.CreateBackend(input) +} + +func (s *StorageService) CreateBackend(input StorageBackendConfig) (StorageBackendConfig, error) { input.ID = randomID(10) input.Provider = normalizeStorageProvider(input.Provider) - switch input.Provider { - case StorageProviderSFTP: - input.Type = StorageBackendSFTP - case StorageProviderSMB: - input.Type = StorageBackendSMB - case StorageProviderWebDAV: - input.Type = StorageBackendWebDAV - default: - input.Type = StorageBackendS3 - } + input.Type = storageTypeForProvider(input.Provider) if err := normalizeStorageBackendConfig(&input, true); err != nil { return StorageBackendConfig{}, err } @@ -193,6 +199,10 @@ func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBac } func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) (StorageBackendConfig, error) { + return s.UpdateBackend(id, input) +} + +func (s *StorageService) UpdateBackend(id string, input StorageBackendConfig) (StorageBackendConfig, error) { current, err := s.BackendConfig(id) if err != nil { return StorageBackendConfig{}, err @@ -200,18 +210,19 @@ func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) if current.ID == StorageBackendLocal { return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited") } + current.Provider = canonicalStorageProvider(current) + current.Type = storageTypeForProvider(current.Provider) + input.ID = current.ID - input.Type = current.Type - input.Provider = normalizeStorageProvider(input.Provider) - switch input.Provider { - case StorageProviderSFTP: - input.Type = StorageBackendSFTP - case StorageProviderSMB: - input.Type = StorageBackendSMB - case StorageProviderWebDAV: - input.Type = StorageBackendWebDAV - default: - input.Type = StorageBackendS3 + requestedProvider := normalizeStorageProvider(input.Provider) + requestedType := storageTypeForProvider(requestedProvider) + if input.Type != "" && input.Type != requestedType { + return StorageBackendConfig{}, fmt.Errorf("storage type cannot be changed after creation") + } + input.Provider = requestedProvider + input.Type = requestedType + if input.Provider != current.Provider || input.Type != current.Type { + return StorageBackendConfig{}, fmt.Errorf("storage provider cannot be changed after creation") } if strings.TrimSpace(input.SecretKey) == "" { input.SecretKey = current.SecretKey @@ -374,10 +385,56 @@ func (s *StorageService) TestBackend(id string) (StorageBackendConfig, error) { } if cfg.ID != StorageBackendLocal { _ = s.SaveBackendConfig(cfg) + } else { + _ = s.saveBackendTestStatus(cfg) } return cfg, err } +func (s *StorageService) applyStoredTestStatus(cfg *StorageBackendConfig) { + _ = s.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(storageBackendTestStatusBucket) + if bucket == nil { + return nil + } + data := bucket.Get([]byte(cfg.ID)) + if data == nil { + return nil + } + var status struct { + LastTestedAt time.Time `json:"lastTestedAt,omitempty"` + LastTestError string `json:"lastTestError,omitempty"` + LastTestSuccess bool `json:"lastTestSuccess,omitempty"` + } + if err := json.Unmarshal(data, &status); err != nil { + return nil + } + cfg.LastTestedAt = status.LastTestedAt + cfg.LastTestError = status.LastTestError + cfg.LastTestSuccess = status.LastTestSuccess + return nil + }) +} + +func (s *StorageService) saveBackendTestStatus(cfg StorageBackendConfig) error { + status := struct { + LastTestedAt time.Time `json:"lastTestedAt,omitempty"` + LastTestError string `json:"lastTestError,omitempty"` + LastTestSuccess bool `json:"lastTestSuccess,omitempty"` + }{ + LastTestedAt: cfg.LastTestedAt, + LastTestError: cfg.LastTestError, + LastTestSuccess: cfg.LastTestSuccess, + } + data, err := json.Marshal(status) + if err != nil { + return err + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(storageBackendTestStatusBucket).Put([]byte(cfg.ID), data) + }) +} + func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBackend, error) { switch cfg.Type { case StorageBackendLocal: @@ -424,6 +481,35 @@ func normalizeStorageProvider(provider string) string { } } +func canonicalStorageProvider(cfg StorageBackendConfig) string { + if cfg.Provider != "" && cfg.Provider != StorageBackendLocal { + return normalizeStorageProvider(cfg.Provider) + } + switch cfg.Type { + case StorageBackendSFTP: + return StorageProviderSFTP + case StorageBackendSMB: + return StorageProviderSMB + case StorageBackendWebDAV: + return StorageProviderWebDAV + default: + return StorageProviderS3 + } +} + +func storageTypeForProvider(provider string) string { + switch normalizeStorageProvider(provider) { + case StorageProviderSFTP: + return StorageBackendSFTP + case StorageProviderSMB: + return StorageBackendSMB + case StorageProviderWebDAV: + return StorageBackendWebDAV + default: + return StorageBackendS3 + } +} + func cleanObjectKey(key string) string { return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./") } diff --git a/backend/libs/services/storage_speed.go b/backend/libs/services/storage_speed.go new file mode 100644 index 0000000..20478d8 --- /dev/null +++ b/backend/libs/services/storage_speed.go @@ -0,0 +1,424 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "go.etcd.io/bbolt" +) + +var storageSpeedTestsBucket = []byte("storage_speed_tests") + +const ( + StorageSpeedModeSmall = "small" + StorageSpeedModeBig = "big" + StorageSpeedModeMixed = "mixed" + StorageSpeedModeCustom = "custom" + + StorageSpeedStatusRunning = "running" + StorageSpeedStatusDone = "done" + StorageSpeedStatusFailed = "failed" +) + +type StorageSpeedTest struct { + ID string `json:"id"` + BackendID string `json:"backendId"` + BackendName string `json:"backendName"` + Mode string `json:"mode"` + Status string `json:"status"` + Stage string `json:"stage"` + ProgressPercent int `json:"progressPercent"` + CustomFileCount int `json:"customFileCount,omitempty"` + CustomFileSizeMB float64 `json:"customFileSizeMb,omitempty"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt,omitempty"` + BytesWritten int64 `json:"bytesWritten"` + BytesRead int64 `json:"bytesRead"` + FilesWritten int `json:"filesWritten"` + WriteDurationMS int64 `json:"writeDurationMs"` + ReadDurationMS int64 `json:"readDurationMs"` + DeleteDurationMS int64 `json:"deleteDurationMs"` + Error string `json:"error,omitempty"` +} + +func (t StorageSpeedTest) ModeLabel() string { + switch t.Mode { + case StorageSpeedModeSmall: + return "Many small files" + case StorageSpeedModeBig: + return "One big file" + case StorageSpeedModeMixed: + return "Average mix" + case StorageSpeedModeCustom: + return "Custom" + default: + return t.Mode + } +} + +func (t StorageSpeedTest) StartedLabel() string { + if t.StartedAt.IsZero() { + return "" + } + return t.StartedAt.Format("Jan 2, 15:04:05") +} + +func (t StorageSpeedTest) FinishedLabel() string { + if t.FinishedAt.IsZero() { + return "Still running" + } + return t.FinishedAt.Format("Jan 2, 15:04:05") +} + +func (t StorageSpeedTest) TotalSizeLabel() string { + return FormatMegabytesFromBytes(max(t.BytesWritten, t.BytesRead)) +} + +func (t StorageSpeedTest) WriteSpeedLabel() string { + return speedLabel(t.BytesWritten, t.WriteDurationMS) +} + +func (t StorageSpeedTest) ReadSpeedLabel() string { + return speedLabel(t.BytesRead, t.ReadDurationMS) +} + +func speedLabel(bytes int64, durationMS int64) string { + if bytes <= 0 || durationMS <= 0 { + return "n/a" + } + mb := float64(bytes) / 1024 / 1024 + seconds := float64(durationMS) / 1000 + value := math.Round((mb/seconds)*100) / 100 + return fmt.Sprintf("%.2f MB/s", value) +} + +func (s *StorageService) StartSpeedTest(backendID, mode string) (StorageSpeedTest, error) { + return s.StartSpeedTestWithOptions(backendID, StorageSpeedTestOptions{Mode: mode}) +} + +type StorageSpeedTestOptions struct { + Mode string + CustomFileCount int + CustomFileSizeMB float64 +} + +func (s *StorageService) StartSpeedTestWithOptions(backendID string, options StorageSpeedTestOptions) (StorageSpeedTest, error) { + cfg, err := s.BackendConfig(backendID) + if err != nil { + return StorageSpeedTest{}, err + } + if !cfg.Enabled { + return StorageSpeedTest{}, fmt.Errorf("storage backend is disabled") + } + if !cfg.LastTestSuccess { + return StorageSpeedTest{}, fmt.Errorf("run a successful connection test before testing speed") + } + mode := normalizeSpeedTestMode(options.Mode) + if mode == StorageSpeedModeCustom { + if err := validateCustomSpeedTest(options.CustomFileCount, options.CustomFileSizeMB); err != nil { + return StorageSpeedTest{}, err + } + } + test := StorageSpeedTest{ + ID: randomID(10), + BackendID: cfg.ID, + BackendName: cfg.Name, + Mode: mode, + Status: StorageSpeedStatusRunning, + Stage: "queued", + CustomFileCount: options.CustomFileCount, + CustomFileSizeMB: options.CustomFileSizeMB, + StartedAt: time.Now().UTC(), + } + if err := s.saveSpeedTest(test); err != nil { + return StorageSpeedTest{}, err + } + return test, nil +} + +func (s *StorageService) RunSpeedTest(ctx context.Context, testID string) { + test, err := s.speedTest(testID) + if err != nil { + return + } + if err := s.runSpeedTest(ctx, &test); err != nil { + test.Status = StorageSpeedStatusFailed + test.Error = err.Error() + test.FinishedAt = time.Now().UTC() + if test.Stage == "" || test.Stage == "queued" { + test.Stage = "failed" + } + _ = s.saveSpeedTest(test) + return + } + test.Status = StorageSpeedStatusDone + test.Stage = "complete" + test.ProgressPercent = 100 + test.FinishedAt = time.Now().UTC() + _ = s.saveSpeedTest(test) +} + +func (s *StorageService) ListSpeedTests(backendID string, limit int) ([]StorageSpeedTest, error) { + var tests []StorageSpeedTest + err := s.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket(storageSpeedTestsBucket) + if bucket == nil { + return nil + } + return bucket.ForEach(func(_, value []byte) error { + var test StorageSpeedTest + if err := json.Unmarshal(value, &test); err != nil { + return err + } + if backendID == "" || test.BackendID == backendID { + tests = append(tests, test) + } + return nil + }) + }) + if err != nil { + return nil, err + } + sort.Slice(tests, func(i, j int) bool { + return tests[i].StartedAt.After(tests[j].StartedAt) + }) + if limit > 0 && len(tests) > limit { + tests = tests[:limit] + } + return tests, nil +} + +func (s *StorageService) speedTest(id string) (StorageSpeedTest, error) { + var test StorageSpeedTest + err := s.db.View(func(tx *bbolt.Tx) error { + data := tx.Bucket(storageSpeedTestsBucket).Get([]byte(id)) + if data == nil { + return fmt.Errorf("speed test not found") + } + return json.Unmarshal(data, &test) + }) + return test, err +} + +func (s *StorageService) saveSpeedTest(test StorageSpeedTest) error { + data, err := json.Marshal(test) + if err != nil { + return err + } + return s.db.Update(func(tx *bbolt.Tx) error { + return tx.Bucket(storageSpeedTestsBucket).Put([]byte(test.ID), data) + }) +} + +func (s *StorageService) runSpeedTest(ctx context.Context, test *StorageSpeedTest) error { + backend, err := s.Backend(test.BackendID) + if err != nil { + return err + } + files, err := createSpeedTestFiles(test) + if err != nil { + return err + } + defer os.RemoveAll(files.Root) + keys := make([]string, 0, len(files.Files)) + defer func() { + for _, key := range keys { + _ = backend.Delete(context.Background(), key) + } + }() + + writeStart := time.Now() + for i, file := range files.Files { + key := fmt.Sprintf(".warpbox-speed-test/%s/%03d.bin", test.ID, i) + source, err := os.Open(file.Path) + if err != nil { + return err + } + err = backend.Put(ctx, key, source, file.Size, "application/octet-stream") + source.Close() + if err != nil { + return err + } + keys = append(keys, key) + test.BytesWritten += file.Size + test.FilesWritten++ + updateSpeedProgress(test, "writing", i+1, len(files.Files), 0, 45) + _ = s.saveSpeedTest(*test) + } + test.WriteDurationMS = time.Since(writeStart).Milliseconds() + _ = s.saveSpeedTest(*test) + + readStart := time.Now() + for i, key := range keys { + object, err := backend.Get(ctx, key) + if err != nil { + return err + } + read, err := io.Copy(io.Discard, object.Body) + object.Body.Close() + if err != nil { + return err + } + test.BytesRead += read + updateSpeedProgress(test, "reading", i+1, len(keys), 45, 90) + _ = s.saveSpeedTest(*test) + } + test.ReadDurationMS = time.Since(readStart).Milliseconds() + _ = s.saveSpeedTest(*test) + + deleteStart := time.Now() + for i, key := range keys { + if err := backend.Delete(ctx, key); err != nil { + return err + } + updateSpeedProgress(test, "cleaning up", i+1, len(keys), 90, 100) + _ = s.saveSpeedTest(*test) + } + test.DeleteDurationMS = time.Since(deleteStart).Milliseconds() + keys = nil + return nil +} + +func updateSpeedProgress(test *StorageSpeedTest, stage string, done, total, start, end int) { + test.Stage = stage + if total <= 0 { + test.ProgressPercent = start + return + } + span := end - start + progress := start + int(math.Round(float64(span)*float64(done)/float64(total))) + if progress < 0 { + progress = 0 + } + if progress > 100 { + progress = 100 + } + test.ProgressPercent = progress +} + +type speedTestFile struct { + Path string + Size int64 +} + +type speedTestFiles struct { + Root string + Files []speedTestFile +} + +func createSpeedTestFiles(test *StorageSpeedTest) (speedTestFiles, error) { + plan, err := speedTestPlan(test) + if err != nil { + return speedTestFiles{}, err + } + root, err := os.MkdirTemp("", "warpbox-speed-test-*") + if err != nil { + return speedTestFiles{}, err + } + files := speedTestFiles{Root: root, Files: make([]speedTestFile, 0, len(plan))} + for i, size := range plan { + path := filepath.Join(root, fmt.Sprintf("%03d.bin", i)) + if err := writeMockFile(path, size, byte(65+(i%23))); err != nil { + os.RemoveAll(root) + return speedTestFiles{}, err + } + files.Files = append(files.Files, speedTestFile{Path: path, Size: size}) + } + return files, nil +} + +func speedTestPlan(test *StorageSpeedTest) ([]int64, error) { + mode := normalizeSpeedTestMode(test.Mode) + if mode == StorageSpeedModeCustom { + if err := validateCustomSpeedTest(test.CustomFileCount, test.CustomFileSizeMB); err != nil { + return nil, err + } + size := MegabytesToBytes(test.CustomFileSizeMB) + plan := make([]int64, test.CustomFileCount) + for i := range plan { + plan[i] = size + } + return plan, nil + } + return speedTestPlanForMode(mode), nil +} + +func speedTestPlanForMode(mode string) []int64 { + mode = normalizeSpeedTestMode(mode) + switch mode { + case StorageSpeedModeSmall: + return repeatedSizes(24, 32*1024) + case StorageSpeedModeBig: + return repeatedSizes(1, 8*1024*1024) + default: + sizes := repeatedSizes(8, 64*1024) + return append(sizes, repeatedSizes(1, 4*1024*1024)...) + } +} + +func repeatedSizes(count int, size int64) []int64 { + sizes := make([]int64, 0, count) + for i := 0; i < count; i++ { + sizes = append(sizes, size) + } + return sizes +} + +func writeMockFile(path string, size int64, seed byte) error { + target, err := os.Create(path) + if err != nil { + return err + } + defer target.Close() + chunk := make([]byte, 64*1024) + for i := range chunk { + chunk[i] = seed + } + remaining := size + for remaining > 0 { + writeSize := int64(len(chunk)) + if remaining < writeSize { + writeSize = remaining + } + if _, err := target.Write(chunk[:int(writeSize)]); err != nil { + return err + } + remaining -= writeSize + } + return nil +} + +func validateCustomSpeedTest(count int, sizeMB float64) error { + if count <= 0 || count > 500 { + return fmt.Errorf("custom speed test file count must be between 1 and 500") + } + if sizeMB <= 0 { + return fmt.Errorf("custom speed test file size must be positive") + } + totalMB := float64(count) * sizeMB + if totalMB > 4096 { + return fmt.Errorf("custom speed test total size cannot exceed 4096 MB") + } + return nil +} + +func normalizeSpeedTestMode(mode string) string { + switch strings.TrimSpace(mode) { + case StorageSpeedModeSmall: + return StorageSpeedModeSmall + case StorageSpeedModeBig: + return StorageSpeedModeBig + case StorageSpeedModeCustom: + return StorageSpeedModeCustom + default: + return StorageSpeedModeMixed + } +} diff --git a/backend/libs/services/upload_test.go b/backend/libs/services/upload_test.go index 36ba4b3..4a58fa3 100644 --- a/backend/libs/services/upload_test.go +++ b/backend/libs/services/upload_test.go @@ -172,6 +172,157 @@ func TestSFTPStorageConfigValidation(t *testing.T) { } } +func TestStorageUpdateRejectsProviderMutation(t *testing.T) { + service := newTestUploadService(t) + cfg, err := service.Storage().CreateBackend(StorageBackendConfig{ + Provider: StorageProviderSFTP, + Name: "SFTP", + Host: "files.example.test", + Username: "warpbox", + Password: "secret", + }) + if err != nil { + t.Fatalf("CreateBackend returned error: %v", err) + } + if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{ + Provider: StorageProviderS3, + Name: "Mutated", + Endpoint: "https://s3.example.test", + Bucket: "bucket", + AccessKey: "access", + SecretKey: "secret", + UseSSL: true, + }); err == nil { + t.Fatalf("UpdateBackend allowed provider mutation") + } + stored, err := service.Storage().BackendConfig(cfg.ID) + if err != nil { + t.Fatalf("BackendConfig returned error: %v", err) + } + if stored.Provider != StorageProviderSFTP || stored.Type != StorageBackendSFTP { + t.Fatalf("provider/type mutated despite error: %+v", stored) + } + if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{ + Provider: StorageProviderSFTP, + Type: StorageBackendS3, + Name: "Mutated", + Host: "files.example.test", + Username: "warpbox", + Password: "secret", + }); err == nil { + t.Fatalf("UpdateBackend allowed type mutation") + } +} + +func TestStorageUpdatePreservesSecretsWhenBlank(t *testing.T) { + service := newTestUploadService(t) + cfg, err := service.Storage().CreateBackend(StorageBackendConfig{ + Provider: StorageProviderSFTP, + Name: "SFTP", + Host: "files.example.test", + Username: "warpbox", + Password: "secret", + PrivateKey: "private-key", + HostKey: "host-key", + }) + if err != nil { + t.Fatalf("CreateBackend returned error: %v", err) + } + updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{ + Provider: StorageProviderSFTP, + Name: "SFTP renamed", + Host: "files.example.test", + Username: "warpbox", + }) + if err != nil { + t.Fatalf("UpdateBackend returned error: %v", err) + } + if updated.Password != "secret" || updated.PrivateKey != "private-key" || updated.HostKey != "host-key" { + t.Fatalf("blank secret fields were not preserved: %+v", updated) + } +} + +func TestContaboUpdateKeepsTLSAndPathStyleLocked(t *testing.T) { + service := newTestUploadService(t) + cfg, err := service.Storage().CreateBackend(StorageBackendConfig{ + Provider: StorageProviderContabo, + Name: "Contabo", + Endpoint: "https://eu2.contabostorage.com", + Bucket: "My Main Bucket", + AccessKey: "access", + SecretKey: "secret", + }) + if err != nil { + t.Fatalf("CreateBackend returned error: %v", err) + } + updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{ + Provider: StorageProviderContabo, + Name: "Contabo", + Endpoint: "https://eu2.contabostorage.com", + Bucket: "My Main Bucket", + AccessKey: "access", + SecretKey: "secret", + UseSSL: false, + PathStyle: false, + }) + if err != nil { + t.Fatalf("UpdateBackend returned error: %v", err) + } + if !updated.UseSSL || !updated.PathStyle { + t.Fatalf("contabo TLS/path-style were not locked: %+v", updated) + } +} + +func TestStorageSpeedTestRequiresConnectionAndRuns(t *testing.T) { + service := newTestUploadService(t) + if _, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall); err == nil { + t.Fatalf("StartSpeedTest allowed speed test before connection test") + } + if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil { + t.Fatalf("TestBackend local returned error: %v", err) + } + test, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall) + if err != nil { + t.Fatalf("StartSpeedTest returned error: %v", err) + } + service.Storage().RunSpeedTest(testContext(), test.ID) + tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10) + if err != nil { + t.Fatalf("ListSpeedTests returned error: %v", err) + } + if len(tests) != 1 { + t.Fatalf("speed tests len = %d, want 1", len(tests)) + } + got := tests[0] + if got.Status != StorageSpeedStatusDone || got.ProgressPercent != 100 || got.Stage != "complete" || got.BytesWritten == 0 || got.BytesRead == 0 || got.FilesWritten == 0 { + t.Fatalf("speed test did not complete with metrics: %+v", got) + } +} + +func TestCustomStorageSpeedTestUsesRequestedFiles(t *testing.T) { + service := newTestUploadService(t) + if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil { + t.Fatalf("TestBackend local returned error: %v", err) + } + test, err := service.Storage().StartSpeedTestWithOptions(StorageBackendLocal, StorageSpeedTestOptions{ + Mode: StorageSpeedModeCustom, + CustomFileCount: 3, + CustomFileSizeMB: 0.001, + }) + if err != nil { + t.Fatalf("StartSpeedTestWithOptions returned error: %v", err) + } + service.Storage().RunSpeedTest(testContext(), test.ID) + tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10) + if err != nil { + t.Fatalf("ListSpeedTests returned error: %v", err) + } + got := tests[0] + if got.Mode != StorageSpeedModeCustom || got.CustomFileCount != 3 || got.CustomFileSizeMB != 0.001 || got.FilesWritten != 3 || got.Status != StorageSpeedStatusDone { + t.Fatalf("custom speed test did not use requested files: %+v", got) + } +} + func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) { service := newTestUploadService(t) smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{ diff --git a/backend/static/css/60-storage.css b/backend/static/css/60-storage.css index f97d402..61190ed 100644 --- a/backend/static/css/60-storage.css +++ b/backend/static/css/60-storage.css @@ -79,6 +79,7 @@ align-items: center; gap: 0.4rem; flex-shrink: 0; + flex-wrap: wrap; } /* View-mode summary */ @@ -123,7 +124,6 @@ /* Edit-mode body */ .storage-card:not(.is-editing) .storage-card-body { display: none; } .storage-card.is-editing .storage-card-summary { display: none; } -.storage-card.is-editing .storage-edit-trigger { display: none; } .storage-card-body { border-top: 1px solid var(--border); @@ -173,18 +173,6 @@ } } -/* Add storage section */ -.storage-add-section { - display: grid; - gap: 0.75rem; -} - -.storage-add-controls { - display: flex; - align-items: center; - gap: 0.65rem; -} - .storage-type-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); @@ -203,6 +191,7 @@ font: inherit; text-align: left; cursor: pointer; + text-decoration: none; transition: border-color 120ms ease, background 120ms ease; } @@ -229,16 +218,249 @@ line-height: 1.4; } -.storage-new-card { - border: 1px dashed rgba(125, 211, 252, 0.4); - border-radius: var(--radius); - background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15)); +.storage-ops-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; + margin-bottom: 1rem; } -.storage-new-card .storage-card-header { +.storage-op-card { + display: grid; + gap: 0.5rem; + align-content: start; + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 94%, transparent); +} + +.storage-op-card strong { + color: var(--foreground); + font-size: 0.9rem; +} + +.storage-op-card span { + color: var(--muted-foreground); + font-size: 0.78rem; + line-height: 1.45; +} + +.storage-op-card .button { + justify-self: start; + margin-top: 0.15rem; +} + +.storage-form-note { + grid-column: 1 / -1; + margin: 0; + padding: 0.7rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--muted); + color: var(--muted-foreground); + font-size: 0.78rem; + line-height: 1.45; +} + +.storage-modal[hidden] { + display: none; +} + +.storage-modal { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 1rem; +} + +.storage-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); +} + +.storage-modal-card { + position: relative; + z-index: 1; + width: min(30rem, 100%); + max-height: min(42rem, 90vh); + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + box-shadow: var(--shadow, 0 1rem 2.5rem rgba(0, 0, 0, 0.35)); +} + +.storage-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 0.9rem; border-bottom: 1px solid var(--border); } -.storage-new-card .storage-card-body { - border-top: none; +.storage-speed-form, +.storage-results-list { + display: grid; + gap: 0.65rem; + padding: 0.9rem; +} + +.storage-results-page { + padding: 0; + margin-top: 1rem; +} + +.storage-tests-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.storage-speed-option { + display: flex; + gap: 0.65rem; + align-items: flex-start; + padding: 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 90%, var(--muted)); + cursor: pointer; +} + +.storage-speed-option span { + display: grid; + gap: 0.18rem; +} + +.storage-speed-option small { + color: var(--muted-foreground); + font-size: 0.72rem; + line-height: 1.35; +} + +.storage-custom-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + padding: 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--muted); +} + +.storage-custom-fields[hidden] { + display: none; +} + +.storage-custom-fields label { + display: grid; + gap: 0.25rem; + color: var(--muted-foreground); + font-size: 0.76rem; +} + +.storage-result-row { + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 92%, transparent); +} + +.storage-result-row summary { + display: grid; + grid-template-columns: 1.2fr 1fr auto; + gap: 0.6rem; + align-items: center; + padding: 0.65rem 0.75rem; + cursor: pointer; + font-size: 0.78rem; +} + +.storage-test-progress { + display: grid; + gap: 0.25rem; + padding: 0 0.75rem 0.65rem; +} + +.storage-test-progress-bar { + height: 0.45rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--muted); +} + +.storage-test-progress-bar span { + display: block; + height: 100%; + width: 0; + background: color-mix(in srgb, var(--primary) 70%, #86efac); + transition: width 180ms ease; +} + +.storage-test-progress small { + color: var(--muted-foreground); + font-size: 0.72rem; +} + +.storage-result-status { + justify-self: end; + padding: 0.12rem 0.45rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted-foreground); + font-size: 0.7rem; + text-transform: uppercase; +} + +.storage-result-status.is-done { color: #86efac; } +.storage-result-status.is-failed { color: #fca5a5; } +.storage-result-status.is-running { color: #fde68a; } + +.storage-result-detail { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + padding: 0 0.75rem 0.75rem; +} + +.storage-result-detail span { + display: grid; + gap: 0.12rem; + color: var(--foreground); + font-size: 0.76rem; + min-width: 0; +} + +.storage-result-detail strong { + color: var(--muted-foreground); + font-size: 0.68rem; + text-transform: uppercase; +} + +.storage-result-error { + grid-column: 1 / -1; + color: #fca5a5 !important; + word-break: break-word; +} + +@media (max-width: 860px) { + .storage-ops-grid { + grid-template-columns: 1fr; + } + + .storage-result-row summary, + .storage-result-detail, + .storage-custom-fields { + grid-template-columns: 1fr; + } + + .storage-result-status { + justify-self: start; + } } diff --git a/backend/static/js/20-storage-admin.js b/backend/static/js/20-storage-admin.js index 838f35e..798199b 100644 --- a/backend/static/js/20-storage-admin.js +++ b/backend/static/js/20-storage-admin.js @@ -1,125 +1,115 @@ (function () { - const storageProviderSelects = document.querySelectorAll("[data-storage-provider]"); + document.querySelectorAll("[data-storage-speed-open]").forEach((button) => { + button.addEventListener("click", () => { + const modal = document.querySelector("[data-storage-speed-modal]"); + if (modal) { + modal.hidden = false; + } + }); + }); - function syncStorageProvider(select) { - const formScope = select.closest("form"); - if (!formScope) { + document.querySelectorAll("[data-storage-modal-close]").forEach((button) => { + button.addEventListener("click", () => { + const modal = button.closest(".storage-modal"); + if (modal) { + modal.hidden = true; + } + }); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") { return; } - const provider = select.value; - const isContabo = provider === "contabo"; - formScope.querySelectorAll("[data-provider-fields]").forEach((group) => { - const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/); - const active = providers.includes(provider); - group.hidden = !active; - group.querySelectorAll("input, select, textarea").forEach((input) => { - input.disabled = !active; - }); + document.querySelectorAll(".storage-modal").forEach((modal) => { + modal.hidden = true; }); - const tls = formScope.querySelector('input[name="use_ssl"]'); - const pathStyle = formScope.querySelector('input[name="path_style"]'); - if (tls) { - tls.checked = isContabo || tls.checked; - tls.disabled = isContabo; - } - if (pathStyle) { - pathStyle.checked = isContabo || pathStyle.checked; - pathStyle.disabled = isContabo; - } - } - - storageProviderSelects.forEach((select) => { - select.addEventListener("change", () => syncStorageProvider(select)); - syncStorageProvider(select); }); - document.querySelectorAll(".storage-edit-trigger").forEach((button) => { - button.addEventListener("click", () => { - const card = button.closest(".storage-card"); - if (!card) { + document.querySelectorAll(".storage-speed-form").forEach((form) => { + const customFields = form.querySelector("[data-storage-custom-fields]"); + function syncCustomFields() { + if (!customFields) { return; } - card.classList.add("is-editing"); - const providerSelect = card.querySelector("[data-storage-provider]"); - if (providerSelect) { - syncStorageProvider(providerSelect); - } - }); - }); - - document.querySelectorAll(".storage-cancel-trigger").forEach((button) => { - button.addEventListener("click", () => { - const card = button.closest(".storage-card"); - if (!card) { - return; - } - const form = card.querySelector("form"); - if (form) { - form.reset(); - } - card.classList.remove("is-editing"); - }); - }); - - const storageAddTrigger = document.querySelector(".storage-add-trigger"); - const storageTypePicker = document.querySelector(".storage-type-picker"); - const storageNewCard = document.querySelector(".storage-new-card"); - - const providerLabels = { - s3: "S3 bucket", - contabo: "Contabo Object Storage", - sftp: "SFTP", - smb: "Samba", - webdav: "WebDAV", - }; - - if (storageAddTrigger && storageTypePicker) { - storageAddTrigger.addEventListener("click", () => { - storageTypePicker.hidden = !storageTypePicker.hidden; - if (storageNewCard && !storageTypePicker.hidden) { - storageNewCard.hidden = true; - } - }); - - storageTypePicker.querySelectorAll(".storage-type-option").forEach((option) => { - option.addEventListener("click", () => { - const provider = option.dataset.provider; - if (!storageNewCard) { - return; - } - - const providerSelect = storageNewCard.querySelector("[data-storage-provider]"); - if (providerSelect) { - providerSelect.value = provider; - syncStorageProvider(providerSelect); - } - - const typeBadge = storageNewCard.querySelector(".storage-new-type-badge"); - if (typeBadge) { - typeBadge.textContent = providerLabels[provider] || provider; - } - - const iconEl = storageNewCard.querySelector(".storage-new-icon"); - const optIcon = option.querySelector("svg"); - if (iconEl && optIcon) { - iconEl.innerHTML = optIcon.outerHTML; - } - - storageTypePicker.hidden = true; - storageNewCard.hidden = false; - }); - }); - } - - if (storageNewCard) { - const cancelBtn = storageNewCard.querySelector(".storage-new-cancel"); - if (cancelBtn) { - cancelBtn.addEventListener("click", () => { - storageNewCard.hidden = true; - if (storageTypePicker) { - storageTypePicker.hidden = true; - } + const customSelected = form.querySelector('input[name="mode"]:checked')?.value === "custom"; + customFields.hidden = !customSelected; + customFields.querySelectorAll("input").forEach((input) => { + input.disabled = !customSelected; }); } + form.querySelectorAll('input[name="mode"]').forEach((input) => { + input.addEventListener("change", syncCustomFields); + }); + syncCustomFields(); + }); + + const testList = document.querySelector("[data-storage-tests-page]"); + if (!testList) { + return; } + + function escapeHTML(value) { + return String(value || "").replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[char]); + } + + function renderTest(test) { + const progress = Math.max(0, Math.min(100, Number(test.progress || 0))); + const error = test.error + ? `Error${escapeHTML(test.error)}` + : ""; + return ` +
+ + ${escapeHTML(test.startedLabel)} + ${escapeHTML(test.customLabel || test.modeLabel)} + ${escapeHTML(test.status)} + +
+
+ ${progress}%${test.stage ? " · " + escapeHTML(test.stage) : ""} +
+
+ Finished${escapeHTML(test.finishedLabel)} + Files${escapeHTML(test.files)} + Size${escapeHTML(test.sizeLabel)} + Write${escapeHTML(test.writeSpeed)} + Read${escapeHTML(test.readSpeed)} + ${error} +
+
`; + } + + async function refreshTests() { + const url = testList.getAttribute("data-storage-tests-url"); + if (!url) { + return; + } + const response = await fetch(url, { headers: { Accept: "application/json" } }); + if (!response.ok) { + return; + } + const payload = await response.json(); + const openIDs = new Set(Array.from(testList.querySelectorAll("details[open]")).map((row) => row.dataset.storageTestId)); + const tests = payload.tests || []; + if (tests.length === 0) { + return; + } + testList.innerHTML = tests.map(renderTest).join(""); + testList.querySelectorAll("details").forEach((row) => { + if (openIDs.has(row.dataset.storageTestId)) { + row.open = true; + } + }); + } + + setInterval(() => { + refreshTests().catch(() => {}); + }, 1200); })(); diff --git a/backend/templates/pages/admin_storage.html b/backend/templates/pages/admin_storage.html index 292fd2c..c79d220 100644 --- a/backend/templates/pages/admin_storage.html +++ b/backend/templates/pages/admin_storage.html @@ -28,15 +28,36 @@

{{.Data.PageTitle}}

Local storage is always active. Remote backends are proxied through Warpbox.

+ {{template "icon-plus-circle" .}}Add storage + {{if .Data.Notice}}

{{.Data.Notice}}

{{end}} {{if .Data.Error}}

{{.Data.Error}}

{{end}} -
+
+
+ + Run cleanup now + Remove expired boxes and boxes that reached their download limit. + +
+
+ + Generate thumbnails now + Scan active boxes and create missing image or video thumbnails. + +
+
+ + Verify storage now + Test every enabled backend and update its last-test status. + +
+
+
{{range .Data.Storage}}
-
@@ -59,12 +80,16 @@
+ {{if .CanSpeedTest}} + Testing + {{else}}
+ {{end}} {{if ne .Config.ID "local"}} - + Edit {{if .Config.Enabled}}
@@ -79,7 +104,6 @@
- {{/* View-mode summary */}}
{{if eq .Config.Type "local"}}
Path{{.Config.LocalPath}}
@@ -107,141 +131,8 @@
{{end}}
- - {{/* Edit-mode form — hidden via CSS until .is-editing */}} - {{if ne .Config.ID "local"}} -
- - - - - - - - - - - - - - - - - - - - - -
- - -
- -
- {{end}} -
{{end}} - - {{/* Add storage section */}} -
-
- -
- - - - -
-
diff --git a/backend/templates/pages/admin_storage_form.html b/backend/templates/pages/admin_storage_form.html new file mode 100644 index 0000000..810edea --- /dev/null +++ b/backend/templates/pages/admin_storage_form.html @@ -0,0 +1,117 @@ +{{define "admin_storage_form.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+ + +
+
+
+

{{if eq .Data.StorageForm.Mode "edit"}}Edit storage{{else}}New storage{{end}}

+

{{.Data.PageTitle}}

+

Provider is locked for this backend. Only fields used by {{.Data.StorageForm.ProviderLabel}} are shown.

+
+ Back +
+ + {{if .Data.Error}}

{{.Data.Error}}

{{end}} + +
+
+
+
+ {{if eq .Data.StorageForm.Provider "sftp"}}{{template "icon-database" .}} + {{else if eq .Data.StorageForm.Provider "smb"}}{{template "icon-folder" .}} + {{else if eq .Data.StorageForm.Provider "webdav"}}{{template "icon-cloud-sync" .}} + {{else}}{{template "icon-cloud-upload" .}}{{end}} +
+
+ {{.Data.StorageForm.ProviderLabel}} +
+ {{.Data.StorageForm.ProviderLabel}} + Immutable provider +
+
+
+
+ +
+
+ + + + + + {{if eq .Data.StorageForm.Provider "s3"}} + + + + + + + + {{end}} + + {{if eq .Data.StorageForm.Provider "contabo"}} + + + + + +

Contabo Object Storage uses TLS and path-style lookup. Warpbox keeps those options locked for this provider.

+ {{end}} + + {{if eq .Data.StorageForm.Provider "sftp"}} + + + + + + + + {{end}} + + {{if eq .Data.StorageForm.Provider "smb"}} + + + + + + + + {{end}} + + {{if eq .Data.StorageForm.Provider "webdav"}} + + + + + {{end}} + +
+ + Cancel +
+
+
+
+
+
+{{end}} diff --git a/backend/templates/pages/admin_storage_new.html b/backend/templates/pages/admin_storage_new.html new file mode 100644 index 0000000..3defdf6 --- /dev/null +++ b/backend/templates/pages/admin_storage_new.html @@ -0,0 +1,50 @@ +{{define "admin_storage_new.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+ + +
+
+
+

Storage provider

+

{{.Data.PageTitle}}

+

Choose the provider first. A backend keeps its provider forever after creation.

+
+ Back +
+ + {{if .Data.Error}}

{{.Data.Error}}

{{end}} + + +
+
+{{end}} diff --git a/backend/templates/pages/admin_storage_tests.html b/backend/templates/pages/admin_storage_tests.html new file mode 100644 index 0000000..95b90ff --- /dev/null +++ b/backend/templates/pages/admin_storage_tests.html @@ -0,0 +1,144 @@ +{{define "admin_storage_tests.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+ + +
+
+
+

Storage testing

+

{{.Data.PageTitle}}

+

Connection status, speed-test history, and background benchmark runs for this backend.

+
+
+ Back + {{if .Data.StorageTest.CanRun}} + + {{end}} +
+
+ + {{if .Data.Notice}}

{{.Data.Notice}}

{{end}} + {{if .Data.Error}}

{{.Data.Error}}

{{end}} + +
+
+
+
+ {{if eq .Data.StorageTest.Config.Type "local"}}{{template "icon-hard-drive" .}} + {{else if eq .Data.StorageTest.Config.Type "sftp"}}{{template "icon-database" .}} + {{else if eq .Data.StorageTest.Config.Type "smb"}}{{template "icon-folder" .}} + {{else if eq .Data.StorageTest.Config.Type "webdav"}}{{template "icon-cloud-sync" .}} + {{else}}{{template "icon-cloud-upload" .}}{{end}} +
+
+ {{.Data.StorageTest.Config.Name}} +
+ {{if eq .Data.StorageTest.Config.Provider "contabo"}}Contabo{{else if eq .Data.StorageTest.Config.Type "sftp"}}SFTP{{else if eq .Data.StorageTest.Config.Type "smb"}}Samba{{else if eq .Data.StorageTest.Config.Type "webdav"}}WebDAV{{else if eq .Data.StorageTest.Config.Type "s3"}}S3{{else if eq .Data.StorageTest.Config.Type "local"}}Local files{{else}}{{.Data.StorageTest.Config.Type}}{{end}} + {{if .Data.StorageTest.Config.LastTestSuccess}}Connection OK{{else}}Needs connection test{{end}} + {{if .Data.StorageTest.UsageLabel}}{{.Data.StorageTest.UsageLabel}}{{end}} +
+
+
+
+ + + +
+
+ {{if not (.Data.StorageTest.Config.LastTestedAt.IsZero)}} +
+
+ Last test + {{.Data.StorageTest.Config.LastTestedAt.Format "Jan 2, 15:04"}} · {{if .Data.StorageTest.Config.LastTestSuccess}}Passed{{else}}{{if .Data.StorageTest.Config.LastTestError}}{{.Data.StorageTest.Config.LastTestError}}{{else}}Failed{{end}}{{end}} +
+
+ {{end}} +
+ + {{if not .Data.StorageTest.CanRun}} +

Run a successful connection test before starting speed tests.

+ {{end}} + +
+ {{if .Data.StorageTest.Tests}} + {{range .Data.StorageTest.Tests}} +
+ + {{.StartedLabel}} + {{if eq .Mode "custom"}}{{.CustomFileCount}} files × {{.CustomFileSizeMB}} MB{{else}}{{.ModeLabel}}{{end}} + {{.Status}} + +
+
+ {{.ProgressPercent}}%{{if .Stage}} · {{.Stage}}{{end}} +
+
+ Finished{{.FinishedLabel}} + Files{{.FilesWritten}} + Size{{.TotalSizeLabel}} + Write{{.WriteSpeedLabel}} + Read{{.ReadSpeedLabel}} + {{if .Error}}Error{{.Error}}{{end}} +
+
+ {{end}} + {{else}} +

No speed tests have been run for this backend yet.

+ {{end}} +
+ + +
+
+{{end}}