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 package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "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 { func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
t.Helper() t.Helper()
user, err := app.authService.UserByID(userID) user, err := app.authService.UserByID(userID)
@@ -638,6 +823,19 @@ func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.Up
return payload 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 { func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
t.Helper() t.Helper()
policy, err := app.settingsService.UploadPolicy() policy, err := app.settingsService.UploadPolicy()

View File

@@ -4,11 +4,15 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"path"
"strconv" "strconv"
"time" "time"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
@@ -22,12 +26,56 @@ type adminPageData struct {
Settings services.UploadPolicySettings Settings services.UploadPolicySettings
Storage []services.StorageBackendView Storage []services.StorageBackendView
UserEdit adminUserEditView UserEdit adminUserEditView
StorageForm adminStorageFormView
StorageTest adminStorageTestView
StorageTypes []adminStorageProviderView
Section string Section string
PageTitle string PageTitle string
LastInviteURL string LastInviteURL string
Notice string
Error 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 { type adminBoxView struct {
ID string ID string
Owner string Owner string
@@ -417,44 +465,167 @@ func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) {
Storage: views, Storage: views,
Section: "storage", Section: "storage",
PageTitle: "Storage", PageTitle: "Storage",
Notice: r.URL.Query().Get("notice"),
Error: r.URL.Query().Get("error"), 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) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return return
} }
rawProvider := adminStorageProviderFromRequest(r)
if !validAdminStorageProvider(rawProvider) {
http.NotFound(w, r)
return
}
provider := normalizeAdminStorageProvider(rawProvider)
if err := r.ParseForm(); err != nil { 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 return
} }
_, err := a.uploadService.Storage().CreateS3Backend(services.StorageBackendConfig{ _, err := a.uploadService.Storage().CreateBackend(a.storageConfigFromForm(r, provider))
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"),
})
if err != nil { 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 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) { 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) http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
return return
} }
_, err := a.uploadService.Storage().UpdateS3Backend(r.PathValue("backendID"), services.StorageBackendConfig{ provider := normalizeAdminStorageProvider(r.FormValue("provider"))
Provider: r.FormValue("provider"), _, err := a.uploadService.Storage().UpdateBackend(r.PathValue("backendID"), a.storageConfigFromForm(r, 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"),
})
if err != nil { 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 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) { func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return 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 { 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 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) { 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) 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) { func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return return
@@ -872,6 +1101,152 @@ func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB" 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) { func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
configs, err := a.uploadService.Storage().ListBackendConfigs() configs, err := a.uploadService.Storage().ListBackendConfigs()
if err != nil { if err != nil {
@@ -886,11 +1261,14 @@ func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
} }
} }
inUse, _ := a.storageBackendInUse(cfg.ID) inUse, _ := a.storageBackendInUse(cfg.ID)
speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25)
views = append(views, services.StorageBackendView{ views = append(views, services.StorageBackendView{
Config: cfg, Config: cfg,
UsageBytes: usage, UsageBytes: usage,
UsageLabel: services.FormatMegabytesFromBytes(usage), UsageLabel: services.FormatMegabytesFromBytes(usage),
InUse: inUse, InUse: inUse,
SpeedTests: speedTests,
CanSpeedTest: cfg.LastTestSuccess,
}) })
} }
return views, nil 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("GET /admin/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost) mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("GET /admin/storage", a.AdminStorage) 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}/edit", a.AdminEditStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage) 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}/disable", a.AdminDisableStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage) 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/invites", a.AdminCreateInvite)
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser) mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser) mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)

View File

@@ -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) { func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
boxes, err := uploadService.ListBoxes(0) boxes, err := uploadService.ListBoxes(0)
if err != nil { if err != nil {

View File

@@ -20,7 +20,7 @@ import (
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
) )
type thumbnailJobResult struct { type ThumbnailJobResult struct {
Scanned int Scanned int
Generated int Generated int
Failed 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) boxes, err := uploadService.ListBoxes(0)
if err != nil { if err != nil {
return thumbnailJobResult{}, err return ThumbnailJobResult{}, err
} }
var result thumbnailJobResult var result ThumbnailJobResult
now := time.Now().UTC() now := time.Now().UTC()
for _, box := range boxes { for _, box := range boxes {
if !box.ExpiresAt.After(now) { if !box.ExpiresAt.After(now) {
@@ -88,8 +92,8 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
return result, nil return result, nil
} }
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (thumbnailJobResult, error) { func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
var result thumbnailJobResult var result ThumbnailJobResult
if !box.ExpiresAt.After(time.Now().UTC()) { if !box.ExpiresAt.After(time.Now().UTC()) {
return result, nil return result, nil
} }

View File

@@ -16,6 +16,7 @@ import (
) )
var storageBackendsBucket = []byte("storage_backends") var storageBackendsBucket = []byte("storage_backends")
var storageBackendTestStatusBucket = []byte("storage_backend_test_status")
const ( const (
StorageBackendLocal = "local" StorageBackendLocal = "local"
@@ -85,6 +86,8 @@ type StorageBackendView struct {
UsageBytes int64 UsageBytes int64
UsageLabel string UsageLabel string
InUse bool InUse bool
SpeedTests []StorageSpeedTest
CanSpeedTest bool
} }
type StorageService struct { type StorageService struct {
@@ -99,7 +102,13 @@ func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) {
} }
service := &StorageService{db: db, localFilesDir: filesDir} service := &StorageService{db: db, localFilesDir: filesDir}
err := db.Update(func(tx *bbolt.Tx) error { 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 return err
}) })
if err != nil { if err != nil {
@@ -126,7 +135,9 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) {
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) { func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" || id == StorageBackendLocal { if id == "" || id == StorageBackendLocal {
return s.localConfig(), nil cfg := s.localConfig()
s.applyStoredTestStatus(&cfg)
return cfg, nil
} }
var cfg StorageBackendConfig var cfg StorageBackendConfig
err := s.db.View(func(tx *bbolt.Tx) error { 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) { 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.ID = randomID(10)
input.Provider = normalizeStorageProvider(input.Provider) input.Provider = normalizeStorageProvider(input.Provider)
switch input.Provider { input.Type = storageTypeForProvider(input.Provider)
case StorageProviderSFTP:
input.Type = StorageBackendSFTP
case StorageProviderSMB:
input.Type = StorageBackendSMB
case StorageProviderWebDAV:
input.Type = StorageBackendWebDAV
default:
input.Type = StorageBackendS3
}
if err := normalizeStorageBackendConfig(&input, true); err != nil { if err := normalizeStorageBackendConfig(&input, true); err != nil {
return StorageBackendConfig{}, err return StorageBackendConfig{}, err
} }
@@ -193,6 +199,10 @@ func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBac
} }
func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) (StorageBackendConfig, error) { 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) current, err := s.BackendConfig(id)
if err != nil { if err != nil {
return StorageBackendConfig{}, err return StorageBackendConfig{}, err
@@ -200,18 +210,19 @@ func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig)
if current.ID == StorageBackendLocal { if current.ID == StorageBackendLocal {
return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited") return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited")
} }
current.Provider = canonicalStorageProvider(current)
current.Type = storageTypeForProvider(current.Provider)
input.ID = current.ID input.ID = current.ID
input.Type = current.Type requestedProvider := normalizeStorageProvider(input.Provider)
input.Provider = normalizeStorageProvider(input.Provider) requestedType := storageTypeForProvider(requestedProvider)
switch input.Provider { if input.Type != "" && input.Type != requestedType {
case StorageProviderSFTP: return StorageBackendConfig{}, fmt.Errorf("storage type cannot be changed after creation")
input.Type = StorageBackendSFTP }
case StorageProviderSMB: input.Provider = requestedProvider
input.Type = StorageBackendSMB input.Type = requestedType
case StorageProviderWebDAV: if input.Provider != current.Provider || input.Type != current.Type {
input.Type = StorageBackendWebDAV return StorageBackendConfig{}, fmt.Errorf("storage provider cannot be changed after creation")
default:
input.Type = StorageBackendS3
} }
if strings.TrimSpace(input.SecretKey) == "" { if strings.TrimSpace(input.SecretKey) == "" {
input.SecretKey = current.SecretKey input.SecretKey = current.SecretKey
@@ -374,10 +385,56 @@ func (s *StorageService) TestBackend(id string) (StorageBackendConfig, error) {
} }
if cfg.ID != StorageBackendLocal { if cfg.ID != StorageBackendLocal {
_ = s.SaveBackendConfig(cfg) _ = s.SaveBackendConfig(cfg)
} else {
_ = s.saveBackendTestStatus(cfg)
} }
return cfg, err 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) { func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBackend, error) {
switch cfg.Type { switch cfg.Type {
case StorageBackendLocal: 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 { func cleanObjectKey(key string) string {
return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./") return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./")
} }

View File

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

View File

@@ -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) { func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) {
service := newTestUploadService(t) service := newTestUploadService(t)
smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{ smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{

View File

@@ -79,6 +79,7 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
flex-shrink: 0; flex-shrink: 0;
flex-wrap: wrap;
} }
/* View-mode summary */ /* View-mode summary */
@@ -123,7 +124,6 @@
/* Edit-mode body */ /* Edit-mode body */
.storage-card:not(.is-editing) .storage-card-body { display: none; } .storage-card:not(.is-editing) .storage-card-body { display: none; }
.storage-card.is-editing .storage-card-summary { display: none; } .storage-card.is-editing .storage-card-summary { display: none; }
.storage-card.is-editing .storage-edit-trigger { display: none; }
.storage-card-body { .storage-card-body {
border-top: 1px solid var(--border); 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 { .storage-type-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
@@ -203,6 +191,7 @@
font: inherit; font: inherit;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease; transition: border-color 120ms ease, background 120ms ease;
} }
@@ -229,16 +218,249 @@
line-height: 1.4; line-height: 1.4;
} }
.storage-new-card { .storage-ops-grid {
border: 1px dashed rgba(125, 211, 252, 0.4); display: grid;
border-radius: var(--radius); grid-template-columns: repeat(3, minmax(0, 1fr));
background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15)); 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); border-bottom: 1px solid var(--border);
} }
.storage-new-card .storage-card-body { .storage-speed-form,
border-top: none; .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;
}
} }

View File

@@ -1,125 +1,115 @@
(function () { (function () {
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]"); document.querySelectorAll("[data-storage-speed-open]").forEach((button) => {
function syncStorageProvider(select) {
const formScope = select.closest("form");
if (!formScope) {
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;
});
});
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", () => { button.addEventListener("click", () => {
const card = button.closest(".storage-card"); const modal = document.querySelector("[data-storage-speed-modal]");
if (!card) { if (modal) {
return; modal.hidden = false;
}
card.classList.add("is-editing");
const providerSelect = card.querySelector("[data-storage-provider]");
if (providerSelect) {
syncStorageProvider(providerSelect);
} }
}); });
}); });
document.querySelectorAll(".storage-cancel-trigger").forEach((button) => { document.querySelectorAll("[data-storage-modal-close]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const card = button.closest(".storage-card"); const modal = button.closest(".storage-modal");
if (!card) { if (modal) {
modal.hidden = true;
}
});
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") {
return; return;
} }
const form = card.querySelector("form"); document.querySelectorAll(".storage-modal").forEach((modal) => {
if (form) { modal.hidden = true;
form.reset(); });
});
document.querySelectorAll(".storage-speed-form").forEach((form) => {
const customFields = form.querySelector("[data-storage-custom-fields]");
function syncCustomFields() {
if (!customFields) {
return;
} }
card.classList.remove("is-editing"); const customSelected = form.querySelector('input[name="mode"]:checked')?.value === "custom";
customFields.hidden = !customSelected;
customFields.querySelectorAll("input").forEach((input) => {
input.disabled = !customSelected;
}); });
});
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;
} }
form.querySelectorAll('input[name="mode"]').forEach((input) => {
input.addEventListener("change", syncCustomFields);
});
syncCustomFields();
}); });
storageTypePicker.querySelectorAll(".storage-type-option").forEach((option) => { const testList = document.querySelector("[data-storage-tests-page]");
option.addEventListener("click", () => { if (!testList) {
const provider = option.dataset.provider;
if (!storageNewCard) {
return; return;
} }
const providerSelect = storageNewCard.querySelector("[data-storage-provider]"); function escapeHTML(value) {
if (providerSelect) { return String(value || "").replace(/[&<>"']/g, (char) => ({
providerSelect.value = provider; "&": "&amp;",
syncStorageProvider(providerSelect); "<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
} }
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge"); function renderTest(test) {
if (typeBadge) { const progress = Math.max(0, Math.min(100, Number(test.progress || 0)));
typeBadge.textContent = providerLabels[provider] || provider; const error = test.error
? `<span class="storage-result-error"><strong>Error</strong>${escapeHTML(test.error)}</span>`
: "";
return `
<details class="storage-result-row" data-storage-test-id="${escapeHTML(test.id)}">
<summary>
<span>${escapeHTML(test.startedLabel)}</span>
<span>${escapeHTML(test.customLabel || test.modeLabel)}</span>
<span class="storage-result-status is-${escapeHTML(test.status)}">${escapeHTML(test.status)}</span>
</summary>
<div class="storage-test-progress" aria-label="Test progress">
<div class="storage-test-progress-bar"><span style="width: ${progress}%"></span></div>
<small>${progress}%${test.stage ? " · " + escapeHTML(test.stage) : ""}</small>
</div>
<div class="storage-result-detail">
<span><strong>Finished</strong>${escapeHTML(test.finishedLabel)}</span>
<span><strong>Files</strong>${escapeHTML(test.files)}</span>
<span><strong>Size</strong>${escapeHTML(test.sizeLabel)}</span>
<span><strong>Write</strong>${escapeHTML(test.writeSpeed)}</span>
<span><strong>Read</strong>${escapeHTML(test.readSpeed)}</span>
${error}
</div>
</details>`;
} }
const iconEl = storageNewCard.querySelector(".storage-new-icon"); async function refreshTests() {
const optIcon = option.querySelector("svg"); const url = testList.getAttribute("data-storage-tests-url");
if (iconEl && optIcon) { if (!url) {
iconEl.innerHTML = optIcon.outerHTML; 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;
} }
storageTypePicker.hidden = true;
storageNewCard.hidden = false;
});
}); });
} }
if (storageNewCard) { setInterval(() => {
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel"); refreshTests().catch(() => {});
if (cancelBtn) { }, 1200);
cancelBtn.addEventListener("click", () => {
storageNewCard.hidden = true;
if (storageTypePicker) {
storageTypePicker.hidden = true;
}
});
}
}
})(); })();

View File

@@ -28,15 +28,36 @@
<h1 id="admin-storage-title">{{.Data.PageTitle}}</h1> <h1 id="admin-storage-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Local storage is always active. Remote backends are proxied through Warpbox.</p> <p class="muted-copy">Local storage is always active. Remote backends are proxied through Warpbox.</p>
</div> </div>
<a class="button button-primary" href="/admin/storage/new">{{template "icon-plus-circle" .}}<span>Add storage</span></a>
</div> </div>
{{if .Data.Notice}}<p class="form-success">{{.Data.Notice}}</p>{{end}}
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}} {{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="storage-stack"> <div class="storage-ops-grid">
<form class="storage-op-card" action="/admin/storage/jobs/cleanup" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<strong>Run cleanup now</strong>
<span>Remove expired boxes and boxes that reached their download limit.</span>
<button class="button button-outline button-sm" type="submit">Run cleanup</button>
</form>
<form class="storage-op-card" action="/admin/storage/jobs/thumbnails" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<strong>Generate thumbnails now</strong>
<span>Scan active boxes and create missing image or video thumbnails.</span>
<button class="button button-outline button-sm" type="submit">Generate</button>
</form>
<form class="storage-op-card" action="/admin/storage/jobs/verify" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<strong>Verify storage now</strong>
<span>Test every enabled backend and update its last-test status.</span>
<button class="button button-outline button-sm" type="submit">Verify all</button>
</form>
</div>
<div class="storage-stack">
{{range .Data.Storage}} {{range .Data.Storage}}
<div class="storage-card {{if eq .Config.ID "local"}}is-local{{end}}" data-storage-id="{{.Config.ID}}"> <div class="storage-card {{if eq .Config.ID "local"}}is-local{{end}}" data-storage-id="{{.Config.ID}}">
<div class="storage-card-header"> <div class="storage-card-header">
<div class="storage-card-identity"> <div class="storage-card-identity">
<div class="storage-card-icon"> <div class="storage-card-icon">
@@ -59,12 +80,16 @@
</div> </div>
<div class="storage-card-actions"> <div class="storage-card-actions">
{{if .CanSpeedTest}}
<a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/tests">Testing</a>
{{else}}
<form action="/admin/storage/{{.Config.ID}}/test" method="post"> <form action="/admin/storage/{{.Config.ID}}/test" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline button-sm" type="submit">Test</button> <button class="button button-outline button-sm" type="submit">Test</button>
</form> </form>
{{end}}
{{if ne .Config.ID "local"}} {{if ne .Config.ID "local"}}
<button class="button button-outline button-sm storage-edit-trigger" type="button">Edit</button> <a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/edit">Edit</a>
{{if .Config.Enabled}} {{if .Config.Enabled}}
<form action="/admin/storage/{{.Config.ID}}/disable" method="post"> <form action="/admin/storage/{{.Config.ID}}/disable" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
@@ -79,7 +104,6 @@
</div> </div>
</div> </div>
{{/* View-mode summary */}}
<div class="storage-card-summary"> <div class="storage-card-summary">
{{if eq .Config.Type "local"}} {{if eq .Config.Type "local"}}
<div class="storage-detail"><span>Path</span><code>{{.Config.LocalPath}}</code></div> <div class="storage-detail"><span>Path</span><code>{{.Config.LocalPath}}</code></div>
@@ -107,141 +131,8 @@
</div> </div>
{{end}} {{end}}
</div> </div>
{{/* Edit-mode form — hidden via CSS until .is-editing */}}
{{if ne .Config.ID "local"}}
<div class="storage-card-body">
<form action="/admin/storage/{{.Config.ID}}/edit" method="post" class="storage-card-fields">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label><span>Storage kind</span>
<select name="provider" data-storage-provider>
<option value="s3" {{if or (eq .Config.Provider "s3") (eq .Config.Provider "")}}selected{{end}}>S3 bucket</option>
<option value="contabo" {{if eq .Config.Provider "contabo"}}selected{{end}}>Contabo Object Storage</option>
<option value="sftp" {{if eq .Config.Provider "sftp"}}selected{{end}}>SFTP</option>
<option value="smb" {{if eq .Config.Provider "smb"}}selected{{end}}>Samba</option>
<option value="webdav" {{if eq .Config.Provider "webdav"}}selected{{end}}>WebDAV</option>
</select>
</label>
<label><span>Name</span><input name="name" value="{{.Config.Name}}" required></label>
<label data-provider-fields="s3 contabo"><span>Endpoint</span><input name="endpoint" value="{{.Config.Endpoint}}" required></label>
<label data-provider-fields="s3 contabo"><span>Region</span><input name="region" value="{{.Config.Region}}"></label>
<label data-provider-fields="s3 contabo"><span>Bucket</span><input name="bucket" value="{{.Config.Bucket}}" required></label>
<label data-provider-fields="s3 contabo"><span>Access key</span><input name="access_key" value="{{.Config.AccessKey}}" required></label>
<label data-provider-fields="s3 contabo"><span>Secret key</span><input name="secret_key" type="password" placeholder="Leave unchanged"></label>
<label class="checkbox-field" data-provider-fields="s3 contabo"><input type="checkbox" name="use_ssl" {{if .Config.UseSSL}}checked{{end}}><span>Use TLS</span></label>
<label class="checkbox-field" data-provider-fields="s3 contabo"><input type="checkbox" name="path_style" {{if .Config.PathStyle}}checked{{end}}><span>Path-style lookup</span></label>
<label data-provider-fields="sftp smb"><span>Host</span><input name="host" value="{{.Config.Host}}" required></label>
<label data-provider-fields="sftp smb"><span>Port</span><input type="number" name="port" min="1" value="{{.Config.Port}}"></label>
<label data-provider-fields="smb"><span>Share</span><input name="share" value="{{.Config.Share}}" required></label>
<label data-provider-fields="smb"><span>Domain</span><input name="domain" value="{{.Config.Domain}}" placeholder="Optional"></label>
<label data-provider-fields="sftp smb webdav"><span>Username</span><input name="username" value="{{.Config.Username}}" required></label>
<label data-provider-fields="sftp smb webdav"><span>Password</span><input name="password" type="password" placeholder="Leave unchanged"></label>
<label data-provider-fields="sftp"><span>Private key</span><textarea name="private_key" rows="4" placeholder="Leave unchanged"></textarea></label>
<label data-provider-fields="sftp"><span>SSH host key</span><textarea name="host_key" rows="3" placeholder="Optional">{{.Config.HostKey}}</textarea></label>
<label data-provider-fields="webdav"><span>WebDAV URL</span><input name="endpoint" value="{{.Config.Endpoint}}" placeholder="https://files.example.com/webdav"></label>
<label data-provider-fields="sftp smb webdav"><span>Remote path</span><input name="remote_path" value="{{.Config.RemotePath}}" placeholder="/srv/warpbox"></label>
<div class="storage-card-edit-bar">
<button class="button button-primary button-sm" type="submit">Save changes</button>
<button class="button button-outline button-sm storage-cancel-trigger" type="button">Cancel</button>
</div>
</form>
</div> </div>
{{end}} {{end}}
</div>
{{end}}
{{/* Add storage section */}}
<div class="storage-add-section">
<div class="storage-add-controls">
<button class="button button-outline storage-add-trigger" type="button">
{{template "icon-plus-circle" .}}
<span>Add storage</span>
</button>
</div>
<div class="storage-type-picker" hidden>
<p class="muted-copy" style="margin:0 0 0.75rem">Choose a backend type</p>
<div class="storage-type-grid">
<button class="storage-type-option" type="button" data-provider="s3">
{{template "icon-cloud-upload" .}}
<strong>S3 Bucket</strong>
<span>Generic S3-compatible object storage</span>
</button>
<button class="storage-type-option" type="button" data-provider="contabo">
{{template "icon-cloud-upload" .}}
<strong>Contabo Object Storage</strong>
<span>Optimized settings for Contabo COS</span>
</button>
<button class="storage-type-option" type="button" data-provider="sftp">
{{template "icon-database" .}}
<strong>SFTP</strong>
<span>SSH file transfer to a server or NAS</span>
</button>
<button class="storage-type-option" type="button" data-provider="smb">
{{template "icon-folder" .}}
<strong>Samba / SMB</strong>
<span>Windows share or network attached storage</span>
</button>
<button class="storage-type-option" type="button" data-provider="webdav">
{{template "icon-cloud-sync" .}}
<strong>WebDAV</strong>
<span>Nextcloud, ownCloud, or any WebDAV server</span>
</button>
</div>
</div>
<div class="storage-new-card storage-card is-editing" hidden>
<div class="storage-card-header">
<div class="storage-card-identity">
<div class="storage-card-icon storage-new-icon">{{template "icon-cloud-upload" .}}</div>
<div>
<strong class="storage-card-name storage-new-label">New storage backend</strong>
<div class="storage-card-meta">
<span class="badge storage-new-type-badge">S3 bucket</span>
</div>
</div>
</div>
</div>
<div class="storage-card-body">
<form action="/admin/storage/s3" method="post" class="storage-card-fields">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label><span>Storage kind</span>
<select name="provider" data-storage-provider>
<option value="s3">S3 bucket</option>
<option value="contabo">Contabo Object Storage</option>
<option value="sftp">SFTP</option>
<option value="smb">Samba</option>
<option value="webdav">WebDAV</option>
</select>
</label>
<label><span>Name</span><input name="name" placeholder="My storage" required></label>
<label data-provider-fields="s3 contabo"><span>Endpoint</span><input name="endpoint" placeholder="s3.example.com" required></label>
<label data-provider-fields="s3 contabo"><span>Region</span><input name="region" placeholder="us-east-1"></label>
<label data-provider-fields="s3 contabo"><span>Bucket</span><input name="bucket" placeholder="my-bucket" required></label>
<label data-provider-fields="s3 contabo"><span>Access key</span><input name="access_key" required></label>
<label data-provider-fields="s3 contabo"><span>Secret key</span><input name="secret_key" type="password" required></label>
<label class="checkbox-field" data-provider-fields="s3 contabo"><input type="checkbox" name="use_ssl" checked><span>Use TLS</span></label>
<label class="checkbox-field" data-provider-fields="s3 contabo"><input type="checkbox" name="path_style"><span>Path-style lookup</span></label>
<label data-provider-fields="sftp smb"><span>Host</span><input name="host" placeholder="files.example.com" required></label>
<label data-provider-fields="sftp smb"><span>Port</span><input type="number" name="port" min="1"></label>
<label data-provider-fields="smb"><span>Share</span><input name="share" placeholder="uploads" required></label>
<label data-provider-fields="smb"><span>Domain</span><input name="domain" placeholder="Optional"></label>
<label data-provider-fields="sftp smb webdav"><span>Username</span><input name="username" required></label>
<label data-provider-fields="sftp smb webdav"><span>Password</span><input name="password" type="password"></label>
<label data-provider-fields="sftp"><span>Private key</span><textarea name="private_key" rows="4" placeholder="Optional private key"></textarea></label>
<label data-provider-fields="sftp"><span>SSH host key</span><textarea name="host_key" rows="3" placeholder="Optional pinned host key"></textarea></label>
<label data-provider-fields="webdav"><span>WebDAV URL</span><input name="endpoint" placeholder="https://files.example.com/webdav"></label>
<label data-provider-fields="sftp smb webdav"><span>Remote path</span><input name="remote_path" placeholder="/srv/warpbox"></label>
<div class="storage-card-edit-bar">
<button class="button button-primary button-sm" type="submit">Add storage</button>
<button class="button button-outline button-sm storage-new-cancel" type="button">Cancel</button>
</div>
</form>
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -0,0 +1,117 @@
{{define "admin_storage_form.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-storage-form-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">{{if eq .Data.StorageForm.Mode "edit"}}Edit storage{{else}}New storage{{end}}</p>
<h1 id="admin-storage-form-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Provider is locked for this backend. Only fields used by {{.Data.StorageForm.ProviderLabel}} are shown.</p>
</div>
<a class="button button-outline" href="{{.Data.StorageForm.BackHref}}">Back</a>
</div>
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="storage-card is-editing">
<div class="storage-card-header">
<div class="storage-card-identity">
<div class="storage-card-icon">
{{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}}
</div>
<div>
<strong class="storage-card-name">{{.Data.StorageForm.ProviderLabel}}</strong>
<div class="storage-card-meta">
<span class="badge">{{.Data.StorageForm.ProviderLabel}}</span>
<span class="badge">Immutable provider</span>
</div>
</div>
</div>
</div>
<div class="storage-card-body">
<form action="{{.Data.StorageForm.Action}}" method="post" class="storage-card-fields">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="provider" value="{{.Data.StorageForm.Provider}}">
<label><span>Name</span><input name="name" value="{{.Data.StorageForm.Config.Name}}" placeholder="My storage" required></label>
{{if eq .Data.StorageForm.Provider "s3"}}
<label><span>Endpoint</span><input name="endpoint" value="{{.Data.StorageForm.Config.Endpoint}}" placeholder="https://s3.example.com" required></label>
<label><span>Region</span><input name="region" value="{{.Data.StorageForm.Config.Region}}" placeholder="us-east-1"></label>
<label><span>Bucket</span><input name="bucket" value="{{.Data.StorageForm.Config.Bucket}}" placeholder="my-bucket" required></label>
<label><span>Access key</span><input name="access_key" value="{{.Data.StorageForm.Config.AccessKey}}" required></label>
<label><span>Secret key</span><input name="secret_key" type="password" placeholder="{{if eq .Data.StorageForm.Mode "edit"}}Leave unchanged{{else}}Secret key{{end}}" {{if ne .Data.StorageForm.Mode "edit"}}required{{end}}></label>
<label class="checkbox-field"><input type="checkbox" name="use_ssl" {{if .Data.StorageForm.Config.UseSSL}}checked{{end}}><span>Use TLS</span></label>
<label class="checkbox-field"><input type="checkbox" name="path_style" {{if .Data.StorageForm.Config.PathStyle}}checked{{end}}><span>Path-style lookup</span></label>
{{end}}
{{if eq .Data.StorageForm.Provider "contabo"}}
<label><span>Endpoint</span><input name="endpoint" value="{{.Data.StorageForm.Config.Endpoint}}" placeholder="https://eu2.contabostorage.com" required></label>
<label><span>Region</span><input name="region" value="{{.Data.StorageForm.Config.Region}}" placeholder="eu2"></label>
<label><span>Bucket display name</span><input name="bucket" value="{{.Data.StorageForm.Config.Bucket}}" placeholder="My Main Bucket" required></label>
<label><span>Access key</span><input name="access_key" value="{{.Data.StorageForm.Config.AccessKey}}" required></label>
<label><span>Secret key</span><input name="secret_key" type="password" placeholder="{{if eq .Data.StorageForm.Mode "edit"}}Leave unchanged{{else}}Secret key{{end}}" {{if ne .Data.StorageForm.Mode "edit"}}required{{end}}></label>
<p class="storage-form-note">Contabo Object Storage uses TLS and path-style lookup. Warpbox keeps those options locked for this provider.</p>
{{end}}
{{if eq .Data.StorageForm.Provider "sftp"}}
<label><span>Host</span><input name="host" value="{{.Data.StorageForm.Config.Host}}" placeholder="files.example.com" required></label>
<label><span>Port</span><input type="number" name="port" min="1" value="{{.Data.StorageForm.Config.Port}}" placeholder="22"></label>
<label><span>Username</span><input name="username" value="{{.Data.StorageForm.Config.Username}}" required></label>
<label><span>Password</span><input name="password" type="password" placeholder="{{if eq .Data.StorageForm.Mode "edit"}}Leave unchanged{{else}}Optional if private key is provided{{end}}"></label>
<label><span>Private key</span><textarea name="private_key" rows="5" placeholder="{{if eq .Data.StorageForm.Mode "edit"}}Leave unchanged{{else}}Optional private key{{end}}"></textarea></label>
<label><span>SSH host key</span><textarea name="host_key" rows="5" placeholder="Optional pinned host key">{{.Data.StorageForm.Config.HostKey}}</textarea></label>
<label><span>Remote path</span><input name="remote_path" value="{{.Data.StorageForm.Config.RemotePath}}" placeholder="/srv/warpbox"></label>
{{end}}
{{if eq .Data.StorageForm.Provider "smb"}}
<label><span>Host</span><input name="host" value="{{.Data.StorageForm.Config.Host}}" placeholder="nas.local" required></label>
<label><span>Port</span><input type="number" name="port" min="1" value="{{.Data.StorageForm.Config.Port}}" placeholder="445"></label>
<label><span>Share</span><input name="share" value="{{.Data.StorageForm.Config.Share}}" placeholder="uploads" required></label>
<label><span>Domain</span><input name="domain" value="{{.Data.StorageForm.Config.Domain}}" placeholder="Optional"></label>
<label><span>Username</span><input name="username" value="{{.Data.StorageForm.Config.Username}}" required></label>
<label><span>Password</span><input name="password" type="password" placeholder="{{if eq .Data.StorageForm.Mode "edit"}}Leave unchanged{{else}}Password{{end}}" {{if ne .Data.StorageForm.Mode "edit"}}required{{end}}></label>
<label><span>Remote path</span><input name="remote_path" value="{{.Data.StorageForm.Config.RemotePath}}" placeholder="/warpbox"></label>
{{end}}
{{if eq .Data.StorageForm.Provider "webdav"}}
<label><span>WebDAV URL</span><input name="endpoint" value="{{.Data.StorageForm.Config.Endpoint}}" placeholder="https://files.example.com/webdav" required></label>
<label><span>Username</span><input name="username" value="{{.Data.StorageForm.Config.Username}}" placeholder="Optional"></label>
<label><span>Password</span><input name="password" type="password" placeholder="{{if eq .Data.StorageForm.Mode "edit"}}Leave unchanged{{else}}Optional{{end}}"></label>
<label><span>Remote path</span><input name="remote_path" value="{{.Data.StorageForm.Config.RemotePath}}" placeholder="/warpbox"></label>
{{end}}
<div class="storage-card-edit-bar">
<button class="button button-primary button-sm" type="submit">{{if eq .Data.StorageForm.Mode "edit"}}Save changes{{else}}Add storage{{end}}</button>
<a class="button button-outline button-sm" href="{{.Data.StorageForm.BackHref}}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,50 @@
{{define "admin_storage_new.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-storage-new-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Storage provider</p>
<h1 id="admin-storage-new-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Choose the provider first. A backend keeps its provider forever after creation.</p>
</div>
<a class="button button-outline" href="/admin/storage">Back</a>
</div>
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="storage-type-grid">
{{range .Data.StorageTypes}}
<a class="storage-type-option" href="/admin/storage/new/{{.Provider}}">
{{if eq .Icon "database"}}{{template "icon-database" $}}
{{else if eq .Icon "folder"}}{{template "icon-folder" $}}
{{else if eq .Icon "sync"}}{{template "icon-cloud-sync" $}}
{{else}}{{template "icon-cloud-upload" $}}{{end}}
<strong>{{.Label}}</strong>
<span>{{.Description}}</span>
</a>
{{end}}
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,144 @@
{{define "admin_storage_tests.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-storage-tests-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Storage testing</p>
<h1 id="admin-storage-tests-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Connection status, speed-test history, and background benchmark runs for this backend.</p>
</div>
<div class="storage-tests-header-actions">
<a class="button button-outline" href="/admin/storage">Back</a>
{{if .Data.StorageTest.CanRun}}
<button class="button button-primary" type="button" data-storage-speed-open>New Test</button>
{{end}}
</div>
</div>
{{if .Data.Notice}}<p class="form-success">{{.Data.Notice}}</p>{{end}}
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="storage-card">
<div class="storage-card-header">
<div class="storage-card-identity">
<div class="storage-card-icon">
{{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}}
</div>
<div>
<strong class="storage-card-name">{{.Data.StorageTest.Config.Name}}</strong>
<div class="storage-card-meta">
<span class="badge">{{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}}</span>
{{if .Data.StorageTest.Config.LastTestSuccess}}<span class="badge badge-active">Connection OK</span>{{else}}<span class="badge badge-disabled">Needs connection test</span>{{end}}
{{if .Data.StorageTest.UsageLabel}}<span class="storage-card-usage">{{.Data.StorageTest.UsageLabel}}</span>{{end}}
</div>
</div>
</div>
<form action="/admin/storage/{{.Data.StorageTest.Config.ID}}/test" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="next" value="tests">
<button class="button button-outline button-sm" type="submit">Test Connection</button>
</form>
</div>
{{if not (.Data.StorageTest.Config.LastTestedAt.IsZero)}}
<div class="storage-card-summary">
<div class="storage-detail storage-detail-test {{if .Data.StorageTest.Config.LastTestSuccess}}is-ok{{else}}is-err{{end}}">
<span>Last test</span>
<span>{{.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}}</span>
</div>
</div>
{{end}}
</div>
{{if not .Data.StorageTest.CanRun}}
<p class="form-error">Run a successful connection test before starting speed tests.</p>
{{end}}
<div class="storage-results-list storage-results-page" data-storage-tests-page data-storage-tests-url="/admin/storage/{{.Data.StorageTest.Config.ID}}/tests.json">
{{if .Data.StorageTest.Tests}}
{{range .Data.StorageTest.Tests}}
<details class="storage-result-row" data-storage-test-id="{{.ID}}">
<summary>
<span>{{.StartedLabel}}</span>
<span>{{if eq .Mode "custom"}}{{.CustomFileCount}} files × {{.CustomFileSizeMB}} MB{{else}}{{.ModeLabel}}{{end}}</span>
<span class="storage-result-status is-{{.Status}}">{{.Status}}</span>
</summary>
<div class="storage-test-progress" aria-label="Test progress">
<div class="storage-test-progress-bar"><span style="width: {{.ProgressPercent}}%"></span></div>
<small>{{.ProgressPercent}}%{{if .Stage}} · {{.Stage}}{{end}}</small>
</div>
<div class="storage-result-detail">
<span><strong>Finished</strong>{{.FinishedLabel}}</span>
<span><strong>Files</strong>{{.FilesWritten}}</span>
<span><strong>Size</strong>{{.TotalSizeLabel}}</span>
<span><strong>Write</strong>{{.WriteSpeedLabel}}</span>
<span><strong>Read</strong>{{.ReadSpeedLabel}}</span>
{{if .Error}}<span class="storage-result-error"><strong>Error</strong>{{.Error}}</span>{{end}}
</div>
</details>
{{end}}
{{else}}
<p class="muted-copy">No speed tests have been run for this backend yet.</p>
{{end}}
</div>
<div class="storage-modal" data-storage-speed-modal hidden>
<div class="storage-modal-backdrop" data-storage-modal-close></div>
<div class="storage-modal-card" role="dialog" aria-modal="true" aria-label="Run storage speed test">
<div class="storage-modal-header">
<strong>New speed test</strong>
<button type="button" class="button button-outline button-sm" data-storage-modal-close>Close</button>
</div>
<form action="/admin/storage/{{.Data.StorageTest.Config.ID}}/speed-test" method="post" class="storage-speed-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label class="storage-speed-option">
<input type="radio" name="mode" value="small">
<span><strong>Many small files test</strong><small>Writes, reads, and deletes many tiny objects.</small></span>
</label>
<label class="storage-speed-option">
<input type="radio" name="mode" value="big">
<span><strong>One big file test</strong><small>Uses one larger object for sequential throughput.</small></span>
</label>
<label class="storage-speed-option">
<input type="radio" name="mode" value="mixed" checked>
<span><strong>Average Test ( mix )</strong><small>Balances small object overhead and larger transfer speed.</small></span>
</label>
<label class="storage-speed-option">
<input type="radio" name="mode" value="custom" data-storage-custom-radio>
<span><strong>Custom</strong><small>Choose how many mock files to create and the size of each file.</small></span>
</label>
<div class="storage-custom-fields" data-storage-custom-fields hidden>
<label><span>Files</span><input type="number" name="custom_file_count" min="1" max="500" value="10"></label>
<label><span>Size per file (MB)</span><input type="number" name="custom_file_size_mb" min="0.001" step="0.001" value="1"></label>
</div>
<button class="button button-primary button-sm" type="submit">Run in background</button>
</form>
</div>
</div>
</div>
</section>
{{end}}