feat(admin): implement provider-specific storage configuration pages
Some checks failed
Build and Publish Docker Image / deploy (push) Has been cancelled

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.
This commit is contained in:
2026-05-31 19:52:46 +03:00
parent ac9b8232f3
commit 1513030c2a
14 changed files with 2031 additions and 355 deletions

View File

@@ -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</span>", "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()

View File

@@ -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

View File

@@ -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)