Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42449b3322 | |||
| 1513030c2a | |||
| ac9b8232f3 | |||
| 704efb019c | |||
| 48d3c0475f | |||
| ffe4201f05 | |||
| df91fe9d3d | |||
| f1c67c455b | |||
| 61b7c283a4 | |||
| d99f8ee82a |
@@ -2,6 +2,9 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Important Nots
|
||||
Do not take screenshots yourself, ask the user to take screenshots of your visual changes if you want to so that you can verify them.
|
||||
|
||||
## Commands
|
||||
|
||||
**Go Executable:**
|
||||
|
||||
@@ -16,12 +16,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
RUN apk add --no-cache ca-certificates ffmpeg wget
|
||||
|
||||
ENV WARPBOX_ADDR=:8080 \
|
||||
WARPBOX_DATA_DIR=/data \
|
||||
WARPBOX_STATIC_DIR=/app/static \
|
||||
WARPBOX_TEMPLATE_DIR=/app/templates
|
||||
WARPBOX_TEMPLATE_DIR=/app/templates \
|
||||
APP_VERSION=${APP_VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
AppVersion string
|
||||
Environment string
|
||||
Addr string
|
||||
BaseURL string
|
||||
@@ -54,6 +55,7 @@ type SettingsDefaults struct {
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||
AppVersion: envString("APP_VERSION", "dev"),
|
||||
Environment: envString("WARPBOX_ENV", "development"),
|
||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||
@@ -72,9 +74,9 @@ func Load() (Config, error) {
|
||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||
DefaultSettings: SettingsDefaults{
|
||||
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||
AnonymousMaxUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
||||
AnonymousDailyUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048),
|
||||
UserDailyUploadMB: envMegabytesFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
|
||||
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
||||
AnonymousDailyUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048),
|
||||
UserDailyUploadMB: envMegabytesLimitFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
|
||||
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
|
||||
UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30),
|
||||
LocalStorageMaxGB: envGigabytesFloat("WARPBOX_LOCAL_STORAGE_MAX_GB", 100),
|
||||
@@ -97,9 +99,9 @@ func Load() (Config, error) {
|
||||
if cfg.MaxUploadSize <= 0 {
|
||||
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
||||
}
|
||||
if cfg.DefaultSettings.AnonymousMaxUploadMB <= 0 ||
|
||||
cfg.DefaultSettings.AnonymousDailyUploadMB <= 0 ||
|
||||
cfg.DefaultSettings.UserDailyUploadMB <= 0 ||
|
||||
if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) ||
|
||||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) ||
|
||||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) ||
|
||||
cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
|
||||
cfg.DefaultSettings.UsageRetentionDays <= 0 ||
|
||||
cfg.DefaultSettings.LocalStorageMaxGB <= 0 ||
|
||||
@@ -111,7 +113,7 @@ func Load() (Config, error) {
|
||||
cfg.DefaultSettings.UserActiveBoxes <= 0 ||
|
||||
cfg.DefaultSettings.ShortWindowRequests <= 0 ||
|
||||
cfg.DefaultSettings.ShortWindowSeconds <= 0 {
|
||||
return Config{}, fmt.Errorf("upload policy settings must be positive")
|
||||
return Config{}, fmt.Errorf("upload policy settings must be positive, with -1 allowed for upload MB limits")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
@@ -203,6 +205,18 @@ func envMegabytesFloat(key string, fallback float64) float64 {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envMegabytesLimitFloat(key string, fallback float64) float64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := parseMegabytesLimitFloat(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envGigabytesFloat(key string, fallback float64) float64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
@@ -246,6 +260,35 @@ func parseMegabytesFloat(value string) (float64, error) {
|
||||
return sizeMB, nil
|
||||
}
|
||||
|
||||
func parseMegabytesLimitFloat(value string) (float64, error) {
|
||||
sizeMB, err := parseMegabytesFloatAllowNegativeOne(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !validUnlimitedMegabyteLimit(sizeMB) {
|
||||
return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited")
|
||||
}
|
||||
return sizeMB, nil
|
||||
}
|
||||
|
||||
func parseMegabytesFloatAllowNegativeOne(value string) (float64, error) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
normalized = strings.TrimSuffix(normalized, "MB")
|
||||
normalized = strings.TrimSuffix(normalized, "Mb")
|
||||
normalized = strings.TrimSuffix(normalized, "mb")
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
|
||||
sizeMB, err := strconv.ParseFloat(normalized, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err)
|
||||
}
|
||||
return sizeMB, nil
|
||||
}
|
||||
|
||||
func validUnlimitedMegabyteLimit(value float64) bool {
|
||||
return value > 0 || value == -1
|
||||
}
|
||||
|
||||
func megabytesToBytes(sizeMB float64) int64 {
|
||||
return int64(math.Round(sizeMB * 1024 * 1024))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -67,6 +68,87 @@ func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenUploadActsAsUser(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("token upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload services.UploadResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox returned error: %v", err)
|
||||
}
|
||||
if box.OwnerID != user.ID {
|
||||
t.Fatalf("OwnerID = %q, want %q", box.OwnerID, user.ID)
|
||||
}
|
||||
|
||||
// An invalid bearer token is an authentication failure, not an anonymous upload.
|
||||
badRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "x.txt", "x")
|
||||
badRequest.Header.Set("Accept", "application/json")
|
||||
badRequest.Header.Set("Authorization", "Bearer wbx_bogus.secret")
|
||||
badResponse := httptest.NewRecorder()
|
||||
app.Upload(badResponse, badRequest)
|
||||
if badResponse.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("invalid token upload status = %d, body = %s", badResponse.Code, badResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousUploadWithoutBearerStillWorks(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, multipartUploadRequest(t, "/api/v1/upload", "file", "anonymous.txt", "anonymous"))
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("anonymous upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUserBearerTokenCannotUpload(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
if err := app.authService.DisableUser(user.ID, true); err != nil {
|
||||
t.Fatalf("DisableUser returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "blocked.txt", "blocked")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("disabled bearer upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -139,6 +221,29 @@ func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlimitedAnonymousUploadPolicyUsesNegativeOne(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
policy, err := app.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
policy.AnonymousMaxUploadMB = -1
|
||||
policy.AnonymousDailyUploadMB = -1
|
||||
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("unlimited anonymous upload status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousUploadDisabled(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -464,6 +569,9 @@ func TestHomeReflectsUploadPolicySettings(t *testing.T) {
|
||||
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
|
||||
t.Fatalf("home did not reflect policy settings: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "warpbox.dev · test ·") {
|
||||
t.Fatalf("home footer did not include app version: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
|
||||
@@ -509,6 +617,190 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/storage/new/sftp", nil)
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminNewStorageProvider(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("AdminNewStorageProvider status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "Private key") || !strings.Contains(body, "SSH host key") {
|
||||
t.Fatalf("sftp page did not render sftp fields: %s", body)
|
||||
}
|
||||
for _, unwanted := range []string{"Bucket display name", "WebDAV URL", "Share</span>", "Access key"} {
|
||||
if strings.Contains(body, unwanted) {
|
||||
t.Fatalf("sftp page rendered irrelevant field %q: %s", unwanted, body)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||
Provider: services.StorageProviderSFTP,
|
||||
Name: "NAS",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
editRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/"+cfg.ID+"/edit", nil)
|
||||
editRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
editRequest.SetPathValue("backendID", cfg.ID)
|
||||
editResponse := httptest.NewRecorder()
|
||||
app.AdminEditStorageForm(editResponse, editRequest)
|
||||
if editResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminEditStorageForm status = %d, body = %s", editResponse.Code, editResponse.Body.String())
|
||||
}
|
||||
editBody := editResponse.Body.String()
|
||||
if !strings.Contains(editBody, "Immutable provider") || strings.Contains(editBody, "Bucket display name") || strings.Contains(editBody, "WebDAV URL") {
|
||||
t.Fatalf("edit page did not stay provider-specific: %s", editBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageEditRejectsProviderMutation(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||
Provider: services.StorageProviderSFTP,
|
||||
Name: "NAS",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
|
||||
form := strings.NewReader("provider=s3&name=Changed&endpoint=https://s3.example.test&bucket=bucket&access_key=access&secret_key=secret&use_ssl=on&csrf_token=test-csrf")
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/edit", form)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
request.SetPathValue("backendID", cfg.ID)
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminEditStorage(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminEditStorage status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
stored, err := app.uploadService.Storage().BackendConfig(cfg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("BackendConfig returned error: %v", err)
|
||||
}
|
||||
if stored.Provider != services.StorageProviderSFTP || stored.Type != services.StorageBackendSFTP || stored.Name != "NAS" {
|
||||
t.Fatalf("storage backend mutated despite rejected provider change: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
unauthorized := httptest.NewRecorder()
|
||||
app.AdminRunStorageCleanup(unauthorized, httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil))
|
||||
if unauthorized.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unauthorized cleanup status = %d", unauthorized.Code)
|
||||
}
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
missingCSRFRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil)
|
||||
missingCSRFRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
missingCSRFResponse := httptest.NewRecorder()
|
||||
app.AdminRunStorageCleanup(missingCSRFResponse, missingCSRFRequest)
|
||||
if missingCSRFResponse.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing csrf cleanup status = %d", missingCSRFResponse.Code)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", strings.NewReader("csrf_token=test-csrf"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminRunStorageCleanup(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("authorized cleanup status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil {
|
||||
t.Fatalf("TestBackend returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/storage/local/speed-test", strings.NewReader("mode=custom&custom_file_count=2&custom_file_size_mb=0.001&csrf_token=test-csrf"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
request.SetPathValue("backendID", services.StorageBackendLocal)
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminStartStorageSpeedTest(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminStartStorageSpeedTest status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
tests, err := app.uploadService.Storage().ListSpeedTests(services.StorageBackendLocal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpeedTests returned error: %v", err)
|
||||
}
|
||||
if len(tests) != 1 {
|
||||
t.Fatalf("speed tests len = %d, want 1", len(tests))
|
||||
}
|
||||
if tests[0].Mode != services.StorageSpeedModeCustom || tests[0].CustomFileCount != 2 || tests[0].CustomFileSizeMB != 0.001 {
|
||||
t.Fatalf("custom speed test options were not stored: %+v", tests[0])
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "/admin/storage/local/tests") {
|
||||
t.Fatalf("speed test redirect location = %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageTestingPageRendersHistory(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil {
|
||||
t.Fatalf("TestBackend returned error: %v", err)
|
||||
}
|
||||
test, err := app.uploadService.Storage().StartSpeedTest(services.StorageBackendLocal, services.StorageSpeedModeSmall)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSpeedTest returned error: %v", err)
|
||||
}
|
||||
app.uploadService.Storage().RunSpeedTest(context.Background(), test.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests", nil)
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
request.SetPathValue("backendID", services.StorageBackendLocal)
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminStorageTests(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("AdminStorageTests status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "New Test") || !strings.Contains(body, "Many small files") || strings.Contains(body, "storage-test-menu") {
|
||||
t.Fatalf("testing page missing expected page-based controls: %s", body)
|
||||
}
|
||||
|
||||
jsonRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests.json", nil)
|
||||
jsonRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
jsonRequest.SetPathValue("backendID", services.StorageBackendLocal)
|
||||
jsonResponse := httptest.NewRecorder()
|
||||
app.AdminStorageTestsJSON(jsonResponse, jsonRequest)
|
||||
if jsonResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminStorageTestsJSON status = %d, body = %s", jsonResponse.Code, jsonResponse.Body.String())
|
||||
}
|
||||
if !strings.Contains(jsonResponse.Body.String(), `"progress":100`) || !strings.Contains(jsonResponse.Body.String(), `"stage":"complete"`) {
|
||||
t.Fatalf("tests json missing progress fields: %s", jsonResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||
t.Helper()
|
||||
user, err := app.authService.UserByID(userID)
|
||||
@@ -534,6 +826,19 @@ func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.Up
|
||||
return payload
|
||||
}
|
||||
|
||||
func createAdminSession(t *testing.T, app *App) string {
|
||||
t.Helper()
|
||||
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil && !strings.Contains(err.Error(), "registration is closed") {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login returned error: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
|
||||
t.Helper()
|
||||
policy, err := app.settingsService.UploadPolicy()
|
||||
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
@@ -22,12 +26,56 @@ type adminPageData struct {
|
||||
Settings services.UploadPolicySettings
|
||||
Storage []services.StorageBackendView
|
||||
UserEdit adminUserEditView
|
||||
StorageForm adminStorageFormView
|
||||
StorageTest adminStorageTestView
|
||||
StorageTypes []adminStorageProviderView
|
||||
Section string
|
||||
PageTitle string
|
||||
LastInviteURL string
|
||||
Notice string
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminStorageFormView struct {
|
||||
Mode string
|
||||
Provider string
|
||||
ProviderLabel string
|
||||
Action string
|
||||
BackHref string
|
||||
Config services.StorageBackendConfig
|
||||
}
|
||||
|
||||
type adminStorageTestView struct {
|
||||
Config services.StorageBackendConfig
|
||||
UsageLabel string
|
||||
Tests []services.StorageSpeedTest
|
||||
CanRun bool
|
||||
}
|
||||
|
||||
type adminStorageSpeedTestJSON struct {
|
||||
ID string `json:"id"`
|
||||
Mode string `json:"mode"`
|
||||
ModeLabel string `json:"modeLabel"`
|
||||
Status string `json:"status"`
|
||||
Stage string `json:"stage"`
|
||||
Progress int `json:"progress"`
|
||||
CustomLabel string `json:"customLabel,omitempty"`
|
||||
StartedLabel string `json:"startedLabel"`
|
||||
FinishedLabel string `json:"finishedLabel"`
|
||||
Files int `json:"files"`
|
||||
SizeLabel string `json:"sizeLabel"`
|
||||
WriteSpeed string `json:"writeSpeed"`
|
||||
ReadSpeed string `json:"readSpeed"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type adminStorageProviderView struct {
|
||||
Provider string
|
||||
Label string
|
||||
Description string
|
||||
Icon string
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
ID string
|
||||
Owner string
|
||||
@@ -359,15 +407,15 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
||||
if value := r.FormValue("user_storage_backend"); value != "" {
|
||||
settings.UserStorageBackend = value
|
||||
}
|
||||
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil {
|
||||
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("anonymous_max_upload_mb")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_daily_upload_mb")); err != nil {
|
||||
if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("anonymous_daily_upload_mb")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if settings.UserDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("user_daily_upload_mb")); err != nil {
|
||||
if settings.UserDailyUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("user_daily_upload_mb")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -417,44 +465,167 @@ func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) {
|
||||
Storage: views,
|
||||
Section: "storage",
|
||||
PageTitle: "Storage",
|
||||
Notice: r.URL.Query().Get("notice"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminCreateS3Storage(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) AdminNewStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, http.StatusOK, "admin_storage_new.html", web.PageData{
|
||||
Title: "Add storage",
|
||||
Description: "Choose a Warpbox storage provider.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Section: "storage",
|
||||
PageTitle: "Add storage",
|
||||
StorageTypes: adminStorageProviderOptions(),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminNewStorageProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
rawProvider := adminStorageProviderFromRequest(r)
|
||||
if !validAdminStorageProvider(rawProvider) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
provider := normalizeAdminStorageProvider(rawProvider)
|
||||
a.renderStorageForm(w, r, http.StatusOK, adminStorageFormView{
|
||||
Mode: "create",
|
||||
Provider: provider,
|
||||
ProviderLabel: adminStorageProviderLabel(provider),
|
||||
Action: "/admin/storage/new/" + provider,
|
||||
BackHref: "/admin/storage/new",
|
||||
Config: services.StorageBackendConfig{
|
||||
Provider: provider,
|
||||
Type: adminStorageTypeForProvider(provider),
|
||||
UseSSL: true,
|
||||
},
|
||||
}, r.URL.Query().Get("error"))
|
||||
}
|
||||
|
||||
func (a *App) AdminEditStorageForm(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID"))
|
||||
if err != nil || cfg.ID == services.StorageBackendLocal {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
provider := normalizeAdminStorageProvider(cfg.Provider)
|
||||
a.renderStorageForm(w, r, http.StatusOK, adminStorageFormView{
|
||||
Mode: "edit",
|
||||
Provider: provider,
|
||||
ProviderLabel: adminStorageProviderLabel(provider),
|
||||
Action: "/admin/storage/" + cfg.ID + "/edit",
|
||||
BackHref: "/admin/storage",
|
||||
Config: cfg,
|
||||
}, r.URL.Query().Get("error"))
|
||||
}
|
||||
|
||||
func (a *App) AdminStorageTests(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tests, err := a.uploadService.Storage().ListSpeedTests(cfg.ID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load storage tests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var usage int64
|
||||
if cfg.Enabled {
|
||||
if backend, err := a.uploadService.Storage().Backend(cfg.ID); err == nil {
|
||||
usage, _ = backend.Usage(context.Background())
|
||||
}
|
||||
}
|
||||
a.renderPage(w, r, http.StatusOK, "admin_storage_tests.html", web.PageData{
|
||||
Title: cfg.Name + " tests",
|
||||
Description: "Storage speed-test history.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Section: "storage",
|
||||
PageTitle: cfg.Name + " tests",
|
||||
StorageTest: adminStorageTestView{
|
||||
Config: cfg,
|
||||
UsageLabel: services.FormatMegabytesFromBytes(usage),
|
||||
Tests: tests,
|
||||
CanRun: cfg.Enabled && cfg.LastTestSuccess,
|
||||
},
|
||||
Notice: r.URL.Query().Get("notice"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminStorageTestsJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
tests, err := a.uploadService.Storage().ListSpeedTests(cfg.ID, 100)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load storage tests", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
payload := struct {
|
||||
Tests []adminStorageSpeedTestJSON `json:"tests"`
|
||||
}{Tests: adminStorageSpeedTestsJSON(tests)}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
func (a *App) renderStorageForm(w http.ResponseWriter, r *http.Request, status int, form adminStorageFormView, message string) {
|
||||
a.renderPage(w, r, status, "admin_storage_form.html", web.PageData{
|
||||
Title: form.ProviderLabel + " storage",
|
||||
Description: "Configure Warpbox storage.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Section: "storage",
|
||||
PageTitle: form.ProviderLabel + " storage",
|
||||
StorageForm: form,
|
||||
Error: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminCreateStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
rawProvider := adminStorageProviderFromRequest(r)
|
||||
if !validAdminStorageProvider(rawProvider) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
provider := normalizeAdminStorageProvider(rawProvider)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage/new/"+provider, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_, err := a.uploadService.Storage().CreateS3Backend(services.StorageBackendConfig{
|
||||
Provider: r.FormValue("provider"),
|
||||
Name: r.FormValue("name"),
|
||||
Endpoint: r.FormValue("endpoint"),
|
||||
Region: r.FormValue("region"),
|
||||
Bucket: r.FormValue("bucket"),
|
||||
AccessKey: r.FormValue("access_key"),
|
||||
SecretKey: r.FormValue("secret_key"),
|
||||
UseSSL: r.FormValue("use_ssl") == "on",
|
||||
PathStyle: r.FormValue("path_style") == "on",
|
||||
Host: r.FormValue("host"),
|
||||
Port: parsePositiveInt(r.FormValue("port")),
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
PrivateKey: r.FormValue("private_key"),
|
||||
HostKey: r.FormValue("host_key"),
|
||||
RemotePath: r.FormValue("remote_path"),
|
||||
Share: r.FormValue("share"),
|
||||
Domain: r.FormValue("domain"),
|
||||
})
|
||||
_, err := a.uploadService.Storage().CreateBackend(a.storageConfigFromForm(r, provider))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage/new/"+provider+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend added."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -465,42 +636,50 @@ func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
_, err := a.uploadService.Storage().UpdateS3Backend(r.PathValue("backendID"), services.StorageBackendConfig{
|
||||
Provider: r.FormValue("provider"),
|
||||
Name: r.FormValue("name"),
|
||||
Endpoint: r.FormValue("endpoint"),
|
||||
Region: r.FormValue("region"),
|
||||
Bucket: r.FormValue("bucket"),
|
||||
AccessKey: r.FormValue("access_key"),
|
||||
SecretKey: r.FormValue("secret_key"),
|
||||
UseSSL: r.FormValue("use_ssl") == "on",
|
||||
PathStyle: r.FormValue("path_style") == "on",
|
||||
Host: r.FormValue("host"),
|
||||
Port: parsePositiveInt(r.FormValue("port")),
|
||||
Username: r.FormValue("username"),
|
||||
Password: r.FormValue("password"),
|
||||
PrivateKey: r.FormValue("private_key"),
|
||||
HostKey: r.FormValue("host_key"),
|
||||
RemotePath: r.FormValue("remote_path"),
|
||||
Share: r.FormValue("share"),
|
||||
Domain: r.FormValue("domain"),
|
||||
})
|
||||
provider := normalizeAdminStorageProvider(r.FormValue("provider"))
|
||||
_, err := a.uploadService.Storage().UpdateBackend(r.PathValue("backendID"), a.storageConfigFromForm(r, provider))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend updated."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
next := "/admin/storage"
|
||||
if r.FormValue("next") == "tests" {
|
||||
next = "/admin/storage/" + r.PathValue("backendID") + "/tests"
|
||||
}
|
||||
if _, err := a.uploadService.Storage().TestBackend(r.PathValue("backendID")); err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
http.Redirect(w, r, next+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
http.Redirect(w, r, next+"?notice="+url.QueryEscape("Storage connection test passed."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminStartStorageSpeedTest(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape("Unable to read speed test form."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
options := services.StorageSpeedTestOptions{
|
||||
Mode: r.FormValue("mode"),
|
||||
CustomFileCount: parsePositiveInt(r.FormValue("custom_file_count")),
|
||||
CustomFileSizeMB: parsePositiveFloat(r.FormValue("custom_file_size_mb")),
|
||||
}
|
||||
test, err := a.uploadService.Storage().StartSpeedTestWithOptions(r.PathValue("backendID"), options)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
go a.uploadService.Storage().RunSpeedTest(context.Background(), test.ID)
|
||||
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -529,6 +708,56 @@ func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
cleaned, err := jobs.RunCleanupNow(a.uploadService, a.logger)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(fmt.Sprintf("Cleanup finished. Removed %d unavailable boxes.", cleaned)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminRunStorageThumbnails(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
result, err := jobs.RunThumbnailsNow(a.uploadService, a.logger)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("Thumbnail pass finished. Scanned %d files, generated %d, failed %d.", result.Scanned, result.Generated, result.Failed)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
configs, err := a.uploadService.Storage().ListBackendConfigs()
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
passed := 0
|
||||
failed := 0
|
||||
for _, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if _, err := a.uploadService.Storage().TestBackend(cfg.ID); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
passed++
|
||||
}
|
||||
message := fmt.Sprintf("Storage verification finished. %d passed, %d failed.", passed, failed)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
@@ -839,7 +1068,7 @@ func optionalMB(value string) *float64 {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := services.ParseMegabytesValue(value)
|
||||
parsed, err := services.ParseMegabytesLimitValue(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -872,6 +1101,152 @@ func formatMB(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
||||
}
|
||||
|
||||
func adminStorageSpeedTestsJSON(tests []services.StorageSpeedTest) []adminStorageSpeedTestJSON {
|
||||
rows := make([]adminStorageSpeedTestJSON, 0, len(tests))
|
||||
for _, test := range tests {
|
||||
rows = append(rows, adminStorageSpeedTestJSON{
|
||||
ID: test.ID,
|
||||
Mode: test.Mode,
|
||||
ModeLabel: test.ModeLabel(),
|
||||
Status: test.Status,
|
||||
Stage: test.Stage,
|
||||
Progress: test.ProgressPercent,
|
||||
CustomLabel: storageSpeedCustomLabel(test),
|
||||
StartedLabel: test.StartedLabel(),
|
||||
FinishedLabel: test.FinishedLabel(),
|
||||
Files: test.FilesWritten,
|
||||
SizeLabel: test.TotalSizeLabel(),
|
||||
WriteSpeed: test.WriteSpeedLabel(),
|
||||
ReadSpeed: test.ReadSpeedLabel(),
|
||||
Error: test.Error,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func storageSpeedCustomLabel(test services.StorageSpeedTest) string {
|
||||
if test.Mode != services.StorageSpeedModeCustom {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d files × %s each", test.CustomFileCount, services.FormatMegabytesLabel(test.CustomFileSizeMB))
|
||||
}
|
||||
|
||||
func (a *App) storageConfigFromForm(r *http.Request, provider string) services.StorageBackendConfig {
|
||||
cfg := services.StorageBackendConfig{
|
||||
Provider: provider,
|
||||
Name: r.FormValue("name"),
|
||||
}
|
||||
switch provider {
|
||||
case services.StorageProviderSFTP:
|
||||
cfg.Host = r.FormValue("host")
|
||||
cfg.Port = parsePositiveInt(r.FormValue("port"))
|
||||
cfg.Username = r.FormValue("username")
|
||||
cfg.Password = r.FormValue("password")
|
||||
cfg.PrivateKey = r.FormValue("private_key")
|
||||
cfg.HostKey = r.FormValue("host_key")
|
||||
cfg.RemotePath = r.FormValue("remote_path")
|
||||
case services.StorageProviderSMB:
|
||||
cfg.Host = r.FormValue("host")
|
||||
cfg.Port = parsePositiveInt(r.FormValue("port"))
|
||||
cfg.Share = r.FormValue("share")
|
||||
cfg.Domain = r.FormValue("domain")
|
||||
cfg.Username = r.FormValue("username")
|
||||
cfg.Password = r.FormValue("password")
|
||||
cfg.RemotePath = r.FormValue("remote_path")
|
||||
case services.StorageProviderWebDAV:
|
||||
cfg.Endpoint = r.FormValue("endpoint")
|
||||
cfg.Username = r.FormValue("username")
|
||||
cfg.Password = r.FormValue("password")
|
||||
cfg.RemotePath = r.FormValue("remote_path")
|
||||
case services.StorageProviderContabo:
|
||||
cfg.Endpoint = r.FormValue("endpoint")
|
||||
cfg.Region = r.FormValue("region")
|
||||
cfg.Bucket = r.FormValue("bucket")
|
||||
cfg.AccessKey = r.FormValue("access_key")
|
||||
cfg.SecretKey = r.FormValue("secret_key")
|
||||
cfg.UseSSL = true
|
||||
cfg.PathStyle = true
|
||||
default:
|
||||
cfg.Endpoint = r.FormValue("endpoint")
|
||||
cfg.Region = r.FormValue("region")
|
||||
cfg.Bucket = r.FormValue("bucket")
|
||||
cfg.AccessKey = r.FormValue("access_key")
|
||||
cfg.SecretKey = r.FormValue("secret_key")
|
||||
cfg.UseSSL = r.FormValue("use_ssl") == "on"
|
||||
cfg.PathStyle = r.FormValue("path_style") == "on"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func adminStorageProviderOptions() []adminStorageProviderView {
|
||||
return []adminStorageProviderView{
|
||||
{Provider: services.StorageProviderS3, Label: "S3 Bucket", Description: "Generic S3-compatible object storage.", Icon: "cloud"},
|
||||
{Provider: services.StorageProviderContabo, Label: "Contabo Object Storage", Description: "Contabo COS with TLS and path-style lookup locked on.", Icon: "cloud"},
|
||||
{Provider: services.StorageProviderSFTP, Label: "SFTP", Description: "SSH file transfer to a server or NAS.", Icon: "database"},
|
||||
{Provider: services.StorageProviderSMB, Label: "Samba / SMB", Description: "Windows share or network attached storage.", Icon: "folder"},
|
||||
{Provider: services.StorageProviderWebDAV, Label: "WebDAV", Description: "Nextcloud, ownCloud, or any WebDAV endpoint.", Icon: "sync"},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAdminStorageProvider(provider string) string {
|
||||
switch provider {
|
||||
case services.StorageProviderContabo:
|
||||
return services.StorageProviderContabo
|
||||
case services.StorageProviderSFTP:
|
||||
return services.StorageProviderSFTP
|
||||
case services.StorageProviderSMB:
|
||||
return services.StorageProviderSMB
|
||||
case services.StorageProviderWebDAV:
|
||||
return services.StorageProviderWebDAV
|
||||
default:
|
||||
return services.StorageProviderS3
|
||||
}
|
||||
}
|
||||
|
||||
func adminStorageProviderFromRequest(r *http.Request) string {
|
||||
if provider := r.PathValue("provider"); provider != "" {
|
||||
return provider
|
||||
}
|
||||
return path.Base(r.URL.Path)
|
||||
}
|
||||
|
||||
func validAdminStorageProvider(provider string) bool {
|
||||
switch provider {
|
||||
case services.StorageProviderS3, services.StorageProviderContabo, services.StorageProviderSFTP, services.StorageProviderSMB, services.StorageProviderWebDAV:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func adminStorageProviderLabel(provider string) string {
|
||||
switch normalizeAdminStorageProvider(provider) {
|
||||
case services.StorageProviderContabo:
|
||||
return "Contabo Object Storage"
|
||||
case services.StorageProviderSFTP:
|
||||
return "SFTP"
|
||||
case services.StorageProviderSMB:
|
||||
return "Samba / SMB"
|
||||
case services.StorageProviderWebDAV:
|
||||
return "WebDAV"
|
||||
default:
|
||||
return "S3 Bucket"
|
||||
}
|
||||
}
|
||||
|
||||
func adminStorageTypeForProvider(provider string) string {
|
||||
switch normalizeAdminStorageProvider(provider) {
|
||||
case services.StorageProviderSFTP:
|
||||
return services.StorageBackendSFTP
|
||||
case services.StorageProviderSMB:
|
||||
return services.StorageBackendSMB
|
||||
case services.StorageProviderWebDAV:
|
||||
return services.StorageBackendWebDAV
|
||||
default:
|
||||
return services.StorageBackendS3
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
|
||||
configs, err := a.uploadService.Storage().ListBackendConfigs()
|
||||
if err != nil {
|
||||
@@ -886,11 +1261,14 @@ func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
|
||||
}
|
||||
}
|
||||
inUse, _ := a.storageBackendInUse(cfg.ID)
|
||||
speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25)
|
||||
views = append(views, services.StorageBackendView{
|
||||
Config: cfg,
|
||||
UsageBytes: usage,
|
||||
UsageLabel: services.FormatMegabytesFromBytes(usage),
|
||||
InUse: inUse,
|
||||
Config: cfg,
|
||||
UsageBytes: usage,
|
||||
UsageLabel: services.FormatMegabytesFromBytes(usage),
|
||||
InUse: inUse,
|
||||
SpeedTests: speedTests,
|
||||
CanSpeedTest: cfg.LastTestSuccess,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
|
||||
@@ -56,6 +56,8 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
||||
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
||||
mux.HandleFunc("POST /account/password", a.ChangePassword)
|
||||
mux.HandleFunc("POST /account/tokens", a.CreateUserToken)
|
||||
mux.HandleFunc("POST /account/tokens/{tokenID}/delete", a.DeleteUserToken)
|
||||
mux.HandleFunc("GET /admin/login", a.AdminLogin)
|
||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||
@@ -66,11 +68,28 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
|
||||
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
|
||||
mux.HandleFunc("GET /admin/storage", a.AdminStorage)
|
||||
mux.HandleFunc("POST /admin/storage/s3", a.AdminCreateS3Storage)
|
||||
mux.HandleFunc("GET /admin/storage/new", a.AdminNewStorage)
|
||||
mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider)
|
||||
mux.HandleFunc("GET /admin/storage/new/contabo", a.AdminNewStorageProvider)
|
||||
mux.HandleFunc("GET /admin/storage/new/sftp", a.AdminNewStorageProvider)
|
||||
mux.HandleFunc("GET /admin/storage/new/smb", a.AdminNewStorageProvider)
|
||||
mux.HandleFunc("GET /admin/storage/new/webdav", a.AdminNewStorageProvider)
|
||||
mux.HandleFunc("POST /admin/storage/new/s3", a.AdminCreateStorage)
|
||||
mux.HandleFunc("POST /admin/storage/new/contabo", a.AdminCreateStorage)
|
||||
mux.HandleFunc("POST /admin/storage/new/sftp", a.AdminCreateStorage)
|
||||
mux.HandleFunc("POST /admin/storage/new/smb", a.AdminCreateStorage)
|
||||
mux.HandleFunc("POST /admin/storage/new/webdav", a.AdminCreateStorage)
|
||||
mux.HandleFunc("GET /admin/storage/{backendID}/edit", a.AdminEditStorageForm)
|
||||
mux.HandleFunc("GET /admin/storage/{backendID}/tests", a.AdminStorageTests)
|
||||
mux.HandleFunc("GET /admin/storage/{backendID}/tests.json", a.AdminStorageTestsJSON)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage)
|
||||
mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup)
|
||||
mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails)
|
||||
mux.HandleFunc("POST /admin/storage/jobs/verify", a.AdminVerifyStorageBackends)
|
||||
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
|
||||
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
|
||||
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
|
||||
@@ -89,6 +108,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||
mux.HandleFunc("GET /health", a.Health)
|
||||
mux.HandleFunc("GET /healthz", a.Health)
|
||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -122,16 +123,92 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
type apiTokenView struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt string
|
||||
LastUsedAt string
|
||||
}
|
||||
|
||||
type accountData struct {
|
||||
ID string
|
||||
Email string
|
||||
Role string
|
||||
Tokens []apiTokenView
|
||||
NewToken string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
|
||||
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
||||
}
|
||||
|
||||
// CreateUserToken mints a new personal access token and renders the account
|
||||
// page with the one-time plaintext shown. The secret is never recoverable after
|
||||
// this response.
|
||||
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
|
||||
return
|
||||
}
|
||||
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
||||
if err != nil {
|
||||
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
|
||||
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
||||
return
|
||||
}
|
||||
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
|
||||
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
||||
}
|
||||
|
||||
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := a.requireUser(w, r)
|
||||
if !ok || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
||||
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
||||
}
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
|
||||
tokens, err := a.authService.ListAPITokens(user.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
views := make([]apiTokenView, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
lastUsed := "Never"
|
||||
if token.LastUsedAt != nil {
|
||||
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
|
||||
}
|
||||
views = append(views, apiTokenView{
|
||||
ID: token.ID,
|
||||
Name: token.Name,
|
||||
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
|
||||
LastUsedAt: lastUsed,
|
||||
})
|
||||
}
|
||||
data.ID = user.ID
|
||||
data.Email = user.Email
|
||||
data.Role = user.Role
|
||||
data.Tokens = views
|
||||
|
||||
a.renderPage(w, r, status, "account.html", web.PageData{
|
||||
Title: "Account settings",
|
||||
Description: "Manage your Warpbox account.",
|
||||
CurrentUser: a.authService.PublicUser(user),
|
||||
Data: user,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,12 +251,32 @@ func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, pa
|
||||
}
|
||||
|
||||
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
||||
user, ok, _ := a.currentUserWithAuthError(r)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func (a *App) currentUserWithAuthError(r *http.Request) (services.User, bool, error) {
|
||||
// Personal access tokens via Authorization: Bearer act as their owning user.
|
||||
// A bearer header is never set by browsers cross-site, so this path is not
|
||||
// subject to CSRF and intentionally bypasses the session cookie.
|
||||
if header := r.Header.Get("Authorization"); header != "" {
|
||||
if raw, ok := strings.CutPrefix(header, "Bearer "); ok {
|
||||
user, err := a.authService.UserForAPIToken(raw)
|
||||
if err != nil {
|
||||
return services.User{}, false, err
|
||||
}
|
||||
return user, true, nil
|
||||
}
|
||||
}
|
||||
cookie, err := r.Cookie(userSessionCookieName)
|
||||
if err != nil {
|
||||
return services.User{}, false
|
||||
return services.User{}, false, nil
|
||||
}
|
||||
user, _, err := a.authService.UserForSession(cookie.Value)
|
||||
return user, err == nil
|
||||
if err != nil {
|
||||
return services.User{}, false, nil
|
||||
}
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
||||
|
||||
@@ -76,9 +76,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST")
|
||||
title := "Shared files on Warpbox"
|
||||
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
||||
if locked && box.Obfuscate {
|
||||
title = "Protected Warpbox link"
|
||||
description = "This shared box is password protected."
|
||||
}
|
||||
|
||||
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
||||
Title: "Download files",
|
||||
Description: "Download files shared through Warpbox.",
|
||||
Title: title,
|
||||
Description: description,
|
||||
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
|
||||
Data: downloadPageData{
|
||||
Box: boxView{ID: box.ID},
|
||||
Files: files,
|
||||
@@ -87,11 +96,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
Obfuscated: box.Obfuscate,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
|
||||
ExpiresLabel: expiresLabel,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
@@ -141,13 +157,18 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
// The thumbnail isn't generated yet (background job pending). Serve the
|
||||
// placeholder but mark it non-cacheable, otherwise the browser would
|
||||
// keep showing the placeholder until a hard refresh once the real
|
||||
// thumbnail lands. The real thumbnail below is content-stable, so it
|
||||
// gets a long immutable cache.
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
@@ -156,6 +177,14 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||
}
|
||||
|
||||
// servePlaceholderThumbnail serves the fallback image with no-store so the
|
||||
// browser re-requests on the next load and picks up the real thumbnail as soon
|
||||
// as it has been generated.
|
||||
func (a *App) servePlaceholderThumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
}
|
||||
|
||||
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
|
||||
176
backend/libs/handlers/ogimage.go
Normal file
176
backend/libs/handlers/ogimage.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// Open Graph image dimensions recommended for large summary cards
|
||||
// (Discord, Twitter/X, Slack, etc.).
|
||||
const (
|
||||
ogImageWidth = 1200
|
||||
ogImageHeight = 630
|
||||
ogMaxTiles = 4
|
||||
ogTileGap = 8
|
||||
)
|
||||
|
||||
var ogBackground = color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff}
|
||||
|
||||
// BoxOGImage renders the social-preview image for a box: a collage of up to
|
||||
// four file thumbnails, or a branded placeholder when none are available yet.
|
||||
func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||
return
|
||||
}
|
||||
|
||||
// Never leak thumbnails of a locked, obfuscated box. (Protected-but-not-
|
||||
// obfuscated boxes already show their thumbnails on the download page, so
|
||||
// they may appear here too.)
|
||||
hideContents := a.uploadService.IsProtected(box) && box.Obfuscate
|
||||
|
||||
thumbs := make([]image.Image, 0, ogMaxTiles)
|
||||
if !hideContents {
|
||||
for _, file := range box.Files {
|
||||
if len(thumbs) >= ogMaxTiles {
|
||||
break
|
||||
}
|
||||
if file.Thumbnail == "" && file.ThumbnailObjectKey == "" {
|
||||
continue
|
||||
}
|
||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
img, _, decodeErr := image.Decode(object.Body)
|
||||
object.Body.Close()
|
||||
if decodeErr == nil {
|
||||
thumbs = append(thumbs, img)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(thumbs) == 0 {
|
||||
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||
return
|
||||
}
|
||||
a.serveOGImage(w, r, renderCollage(thumbs))
|
||||
}
|
||||
|
||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
http.Error(w, "could not render preview image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
// Social scrapers fetch this rarely and cache on their side; a modest cache
|
||||
// keeps it fresh as thumbnails finish generating.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
http.ServeContent(w, r, "og-image.jpg", time.Time{}, bytes.NewReader(buf.Bytes()))
|
||||
}
|
||||
|
||||
// ogPlaceholder builds the branded fallback image: the file placeholder icon
|
||||
// centered on the brand background.
|
||||
func (a *App) ogPlaceholder() image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
file, err := os.Open(filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
if err != nil {
|
||||
return canvas
|
||||
}
|
||||
defer file.Close()
|
||||
icon, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return canvas
|
||||
}
|
||||
|
||||
// Scale the icon to ~40% of the canvas height and centre it.
|
||||
target := ogImageHeight * 2 / 5
|
||||
b := icon.Bounds()
|
||||
scale := float64(target) / float64(b.Dy())
|
||||
dw := int(float64(b.Dx()) * scale)
|
||||
dh := target
|
||||
x0 := (ogImageWidth - dw) / 2
|
||||
y0 := (ogImageHeight - dh) / 2
|
||||
xdraw.CatmullRom.Scale(canvas, image.Rect(x0, y0, x0+dw, y0+dh), icon, b, xdraw.Over, nil)
|
||||
return canvas
|
||||
}
|
||||
|
||||
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
||||
func renderCollage(thumbs []image.Image) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
cols, rows := collageGrid(len(thumbs))
|
||||
cellW := (ogImageWidth - ogTileGap*(cols+1)) / cols
|
||||
cellH := (ogImageHeight - ogTileGap*(rows+1)) / rows
|
||||
|
||||
i := 0
|
||||
for ry := 0; ry < rows && i < len(thumbs); ry++ {
|
||||
for cx := 0; cx < cols && i < len(thumbs); cx++ {
|
||||
x0 := ogTileGap + cx*(cellW+ogTileGap)
|
||||
y0 := ogTileGap + ry*(cellH+ogTileGap)
|
||||
drawCover(canvas, image.Rect(x0, y0, x0+cellW, y0+cellH), thumbs[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
func collageGrid(n int) (cols, rows int) {
|
||||
switch {
|
||||
case n <= 1:
|
||||
return 1, 1
|
||||
case n == 2:
|
||||
return 2, 1
|
||||
case n == 3:
|
||||
return 3, 1
|
||||
default:
|
||||
return 2, 2
|
||||
}
|
||||
}
|
||||
|
||||
// drawCover scales src to completely fill dst, cropping the overflow (centred),
|
||||
// preserving aspect ratio — the CSS object-fit: cover equivalent.
|
||||
func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) {
|
||||
b := src.Bounds()
|
||||
iw, ih := b.Dx(), b.Dy()
|
||||
if iw <= 0 || ih <= 0 {
|
||||
return
|
||||
}
|
||||
cellAR := float64(cell.Dx()) / float64(cell.Dy())
|
||||
imgAR := float64(iw) / float64(ih)
|
||||
|
||||
var sw, sh int
|
||||
if imgAR > cellAR {
|
||||
// Source is wider than the cell: crop the sides.
|
||||
sh = ih
|
||||
sw = int(float64(ih) * cellAR)
|
||||
} else {
|
||||
// Source is taller: crop top/bottom.
|
||||
sw = iw
|
||||
sh = int(float64(iw) / cellAR)
|
||||
}
|
||||
sx := b.Min.X + (iw-sw)/2
|
||||
sy := b.Min.Y + (ih-sh)/2
|
||||
xdraw.CatmullRom.Scale(dst, cell, src, image.Rect(sx, sy, sx+sw, sy+sh), xdraw.Over, nil)
|
||||
}
|
||||
@@ -9,11 +9,18 @@ import (
|
||||
)
|
||||
|
||||
type homeData struct {
|
||||
MaxUploadSize string
|
||||
LimitSummary string
|
||||
Collections []collectionView
|
||||
IsAdmin bool
|
||||
AnonymousOpen bool
|
||||
MaxUploadSize string
|
||||
LimitSummary string
|
||||
Collections []collectionView
|
||||
IsAdmin bool
|
||||
AnonymousOpen bool
|
||||
ExpiryOptions []expiryOption
|
||||
DefaultExpiryMinutes int
|
||||
}
|
||||
|
||||
type expiryOption struct {
|
||||
Minutes int
|
||||
Label string
|
||||
}
|
||||
|
||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -40,20 +47,92 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
||||
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||
Title: "Upload your files",
|
||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||
CurrentUser: currentUser,
|
||||
Data: homeData{
|
||||
MaxUploadSize: maxUploadSize,
|
||||
LimitSummary: limitSummary,
|
||||
Collections: collections,
|
||||
IsAdmin: isAdmin,
|
||||
AnonymousOpen: settings.AnonymousUploadsEnabled,
|
||||
MaxUploadSize: maxUploadSize,
|
||||
LimitSummary: limitSummary,
|
||||
Collections: collections,
|
||||
IsAdmin: isAdmin,
|
||||
AnonymousOpen: settings.AnonymousUploadsEnabled,
|
||||
ExpiryOptions: expiryOptions,
|
||||
DefaultExpiryMinutes: defaultExpiry,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// homeExpiryOptions builds the expiry ladder offered on the upload form, capped to
|
||||
// the viewer's effective maximum retention. Admins have no cap (the dropdown is
|
||||
// still capped at 365 days for sanity; the API accepts any value for admins).
|
||||
func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) ([]expiryOption, int) {
|
||||
maxDays := settings.AnonymousMaxDays
|
||||
unlimited := false
|
||||
switch {
|
||||
case isAdmin:
|
||||
unlimited = true
|
||||
case loggedIn:
|
||||
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
|
||||
}
|
||||
return buildExpiryOptions(maxDays, unlimited)
|
||||
}
|
||||
|
||||
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
|
||||
ladder := []int{60, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
|
||||
|
||||
capMinutes := maxDays * 24 * 60
|
||||
if unlimited || capMinutes <= 0 {
|
||||
capMinutes = 525600
|
||||
}
|
||||
|
||||
options := make([]expiryOption, 0, len(ladder)+1)
|
||||
seen := make(map[int]bool)
|
||||
for _, minutes := range ladder {
|
||||
if minutes > capMinutes {
|
||||
break
|
||||
}
|
||||
options = append(options, expiryOption{Minutes: minutes, Label: expiryLabel(minutes)})
|
||||
seen[minutes] = true
|
||||
}
|
||||
// Always offer the exact cap as a final choice (e.g. a 15-day limit).
|
||||
if !unlimited && !seen[capMinutes] {
|
||||
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
|
||||
}
|
||||
if len(options) == 0 {
|
||||
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
|
||||
}
|
||||
|
||||
// Default to 24h when available, otherwise the smallest option offered.
|
||||
defaultMinutes := options[0].Minutes
|
||||
if seen[1440] {
|
||||
defaultMinutes = 1440
|
||||
}
|
||||
return options, defaultMinutes
|
||||
}
|
||||
|
||||
func expiryLabel(minutes int) string {
|
||||
switch {
|
||||
case minutes < 60:
|
||||
return strconv.Itoa(minutes) + " minutes"
|
||||
case minutes < 1440:
|
||||
hours := minutes / 60
|
||||
if hours == 1 {
|
||||
return "1 hour"
|
||||
}
|
||||
return strconv.Itoa(hours) + " hours"
|
||||
case minutes == 1440:
|
||||
return "24 hours"
|
||||
default:
|
||||
days := minutes / 1440
|
||||
if days == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
return strconv.Itoa(days) + " days"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
|
||||
if isAdmin {
|
||||
return "No file size limit", "Admin uploads bypass storage and daily caps."
|
||||
@@ -66,7 +145,9 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
|
||||
}
|
||||
policy := a.settingsService.EffectivePolicyForUser(settings, user)
|
||||
maxUpload := a.uploadService.MaxUploadSizeLabel()
|
||||
if policy.MaxUploadMB > 0 {
|
||||
if policy.MaxUploadMB < 0 {
|
||||
maxUpload = "unlimited"
|
||||
} else if policy.MaxUploadMB > 0 {
|
||||
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
|
||||
}
|
||||
quota := "unlimited"
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
func TestSetStaticCacheHeaders(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"/static/css/app.css": "public, max-age=86400",
|
||||
"/static/js/app.js": "public, max-age=86400",
|
||||
"/static/css/00-base.css": "public, max-age=86400",
|
||||
"/static/js/00-utils.js": "public, max-age=86400",
|
||||
"/static/img/preview.webp": "public, max-age=31536000, immutable",
|
||||
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
|
||||
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
|
||||
|
||||
@@ -16,7 +16,11 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
user, loggedIn := a.currentUser(r)
|
||||
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||
if authErr != nil {
|
||||
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||
settings, err := a.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
@@ -35,12 +39,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isAdminUpload {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize()))
|
||||
}
|
||||
parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())
|
||||
if !isAdminUpload && parseLimit > 0 {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, parseLimit)
|
||||
}
|
||||
if isAdminUpload {
|
||||
parseLimit = 32 << 20
|
||||
} else if parseLimit <= 0 {
|
||||
parseLimit = 32 << 20
|
||||
}
|
||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
@@ -73,14 +79,20 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
expiresMinutes := parseInt(r.FormValue("expires_minutes"))
|
||||
if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||
MaxDays: maxDays,
|
||||
ExpiresInMinutes: expiresMinutes,
|
||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||
Password: r.FormValue("password"),
|
||||
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
||||
OwnerID: ownerID,
|
||||
CollectionID: collectionID,
|
||||
SkipSizeLimit: isAdminUpload,
|
||||
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
|
||||
CreatorIP: uploadClientIP(r),
|
||||
StorageBackendID: effectivePolicy.StorageBackendID,
|
||||
})
|
||||
@@ -127,7 +139,7 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||
}
|
||||
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
|
||||
}
|
||||
if usage.UploadedBoxes+1 > policy.DailyBoxes {
|
||||
@@ -150,7 +162,7 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||
}
|
||||
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||
return http.StatusTooManyRequests, "daily upload limit reached"
|
||||
}
|
||||
if usage.UploadedBoxes+1 > policy.DailyBoxes {
|
||||
@@ -210,6 +222,9 @@ func (a *App) checkStorageBackendCapacity(backendID string, settings services.Up
|
||||
}
|
||||
|
||||
func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
|
||||
if policy.MaxUploadMB < 0 {
|
||||
return -1
|
||||
}
|
||||
if loggedIn && policy.MaxUploadMB <= 0 {
|
||||
return fallback * 8
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
cfg := config.Config{
|
||||
AppName: "warpbox.dev",
|
||||
AppVersion: "test",
|
||||
BaseURL: "http://example.test",
|
||||
DataDir: filepath.Join(root, "data"),
|
||||
StaticDir: staticDir,
|
||||
@@ -197,7 +198,7 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
if err != nil {
|
||||
t.Fatalf("NewUploadService returned error: %v", err)
|
||||
}
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
|
||||
if err != nil {
|
||||
service.Close()
|
||||
t.Fatalf("NewRenderer returned error: %v", err)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
|
||||
}
|
||||
}
|
||||
|
||||
func RunCleanupNow(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||
return cleanupUnavailableBoxes(uploadService, logger)
|
||||
}
|
||||
|
||||
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||
boxes, err := uploadService.ListBoxes(0)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
type thumbnailJobResult struct {
|
||||
type ThumbnailJobResult struct {
|
||||
Scanned int
|
||||
Generated int
|
||||
Failed int
|
||||
@@ -63,13 +63,17 @@ func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *ser
|
||||
}
|
||||
}
|
||||
|
||||
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (thumbnailJobResult, error) {
|
||||
func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
|
||||
return generateMissingThumbnails(uploadService, logger)
|
||||
}
|
||||
|
||||
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
|
||||
boxes, err := uploadService.ListBoxes(0)
|
||||
if err != nil {
|
||||
return thumbnailJobResult{}, err
|
||||
return ThumbnailJobResult{}, err
|
||||
}
|
||||
|
||||
var result thumbnailJobResult
|
||||
var result ThumbnailJobResult
|
||||
now := time.Now().UTC()
|
||||
for _, box := range boxes {
|
||||
if !box.ExpiresAt.After(now) {
|
||||
@@ -88,8 +92,8 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (thumbnailJobResult, error) {
|
||||
var result thumbnailJobResult
|
||||
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
|
||||
var result ThumbnailJobResult
|
||||
if !box.ExpiresAt.After(time.Now().UTC()) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ var (
|
||||
sessionsBucket = []byte("sessions")
|
||||
invitesBucket = []byte("invites")
|
||||
collectionsBucket = []byte("collections")
|
||||
apiTokensBucket = []byte("api_tokens")
|
||||
)
|
||||
|
||||
// apiTokenPrefix marks raw API tokens so clients and logs can recognise them.
|
||||
const apiTokenPrefix = "wbx_"
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("api token is invalid")
|
||||
ErrTokenNotFound = errors.New("api token not found")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -111,6 +120,23 @@ type Collection struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// APIToken is a long-lived personal access token. Only the SHA-256 hash of the
|
||||
// secret is stored; the plaintext is shown to the user exactly once at creation.
|
||||
type APIToken struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
TokenHash string `json:"tokenHash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenResult carries the one-time plaintext alongside the stored token.
|
||||
type APITokenResult struct {
|
||||
Token APIToken
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
type InviteResult struct {
|
||||
Invite Invite
|
||||
URL string
|
||||
@@ -120,7 +146,7 @@ type InviteResult struct {
|
||||
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
||||
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
||||
err := db.Update(func(tx *bbolt.Tx) error {
|
||||
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} {
|
||||
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -225,6 +251,131 @@ func (s *AuthService) Logout(raw string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateAPIToken mints a new personal access token for the user. The returned
|
||||
// plaintext is the only time the secret is available; only its hash is stored.
|
||||
func (s *AuthService) CreateAPIToken(userID, name string) (APITokenResult, error) {
|
||||
if userID == "" {
|
||||
return APITokenResult{}, fmt.Errorf("user is required")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "Untitled token"
|
||||
}
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
|
||||
secret := randomID(32)
|
||||
token := APIToken{
|
||||
ID: randomID(12),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
TokenHash: apiTokenHash(secret),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.saveAPIToken(token); err != nil {
|
||||
return APITokenResult{}, err
|
||||
}
|
||||
plaintext := apiTokenPrefix + token.ID + "." + secret
|
||||
return APITokenResult{Token: token, Plaintext: plaintext}, nil
|
||||
}
|
||||
|
||||
// ListAPITokens returns the user's tokens, newest first.
|
||||
func (s *AuthService) ListAPITokens(userID string) ([]APIToken, error) {
|
||||
tokens := make([]APIToken, 0)
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(apiTokensBucket).ForEach(func(_, data []byte) error {
|
||||
var token APIToken
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
if token.UserID == userID {
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(tokens, func(i, j int) bool {
|
||||
return tokens[i].CreatedAt.After(tokens[j].CreatedAt)
|
||||
})
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// DeleteAPIToken removes a token, but only if it belongs to the given user.
|
||||
func (s *AuthService) DeleteAPIToken(userID, tokenID string) error {
|
||||
if userID == "" || tokenID == "" {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(apiTokensBucket)
|
||||
data := bucket.Get([]byte(tokenID))
|
||||
if data == nil {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
var token APIToken
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return err
|
||||
}
|
||||
if token.UserID != userID {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
return bucket.Delete([]byte(tokenID))
|
||||
})
|
||||
}
|
||||
|
||||
// UserForAPIToken resolves a raw bearer token to its owning user. It records
|
||||
// last-used time on a best-effort basis. The user must exist and be enabled.
|
||||
func (s *AuthService) UserForAPIToken(raw string) (User, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, apiTokenPrefix)
|
||||
tokenID, secret, ok := strings.Cut(raw, ".")
|
||||
if !ok || tokenID == "" || secret == "" {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
var token APIToken
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
data := tx.Bucket(apiTokensBucket).Get([]byte(tokenID))
|
||||
if data == nil {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
return json.Unmarshal(data, &token)
|
||||
})
|
||||
if err != nil {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(apiTokenHash(secret)), []byte(token.TokenHash)) != 1 {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
|
||||
user, err := s.UserByID(token.UserID)
|
||||
if err != nil {
|
||||
return User{}, ErrTokenInvalid
|
||||
}
|
||||
if user.Status != UserStatusActive {
|
||||
return User{}, ErrUserDisabled
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token.LastUsedAt = &now
|
||||
_ = s.saveAPIToken(token)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) saveAPIToken(token APIToken) error {
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
data, err := json.Marshal(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Bucket(apiTokensBucket).Put([]byte(token.ID), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
|
||||
email, err := normalizeEmail(email)
|
||||
if err != nil {
|
||||
@@ -673,6 +824,11 @@ func tokenHash(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func apiTokenHash(secret string) string {
|
||||
sum := sha256.Sum256([]byte("warpbox-api-token:" + secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func HashPassword(password string) string {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
@@ -700,11 +856,11 @@ func VerifyPasswordHash(encoded, password string) bool {
|
||||
}
|
||||
|
||||
func validateUserPolicy(policy UserPolicy) error {
|
||||
if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 {
|
||||
return fmt.Errorf("max upload override cannot be negative")
|
||||
if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 && *policy.MaxUploadMB != -1 {
|
||||
return fmt.Errorf("max upload override must be positive or -1 for unlimited")
|
||||
}
|
||||
if policy.DailyUploadMB != nil && *policy.DailyUploadMB <= 0 {
|
||||
return fmt.Errorf("daily upload override must be positive")
|
||||
if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) {
|
||||
return fmt.Errorf("daily upload override must be positive or -1 for unlimited")
|
||||
}
|
||||
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 {
|
||||
return fmt.Errorf("storage quota override cannot be negative")
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -103,6 +104,127 @@ func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenLifecycle(t *testing.T) {
|
||||
auth := newTestAuthService(t)
|
||||
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := auth.CreateAPIToken(user.ID, "CLI laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
|
||||
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
|
||||
}
|
||||
// The secret must never be stored in plaintext — only its hash.
|
||||
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
|
||||
t.Fatalf("stored token hash leaks the plaintext secret")
|
||||
}
|
||||
|
||||
resolved, err := auth.UserForAPIToken(result.Plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("UserForAPIToken returned error: %v", err)
|
||||
}
|
||||
if resolved.ID != user.ID {
|
||||
t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID)
|
||||
}
|
||||
|
||||
tokens, err := auth.ListAPITokens(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("token count = %d, want 1", len(tokens))
|
||||
}
|
||||
if tokens[0].Name != "CLI laptop" {
|
||||
t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop")
|
||||
}
|
||||
if tokens[0].LastUsedAt == nil {
|
||||
t.Fatalf("LastUsedAt not recorded after UserForAPIToken")
|
||||
}
|
||||
|
||||
if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil {
|
||||
t.Fatalf("UserForAPIToken accepted a tampered token")
|
||||
}
|
||||
if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil {
|
||||
t.Fatalf("UserForAPIToken accepted an unknown token")
|
||||
}
|
||||
|
||||
if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil {
|
||||
t.Fatalf("DeleteAPIToken returned error: %v", err)
|
||||
}
|
||||
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
||||
t.Fatalf("deleted token still resolved")
|
||||
}
|
||||
remaining, err := auth.ListAPITokens(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("token count after delete = %d, want 0", len(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) {
|
||||
auth := newTestAuthService(t)
|
||||
owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInvite returned error: %v", err)
|
||||
}
|
||||
other, err := auth.AcceptInvite(invite.Token, "other", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := auth.CreateAPIToken(owner.ID, "owner token")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||
}
|
||||
tokens, err := auth.ListAPITokens(owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||
}
|
||||
|
||||
// Another user cannot delete tokens they do not own.
|
||||
if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil {
|
||||
t.Fatalf("DeleteAPIToken allowed deletion across users")
|
||||
}
|
||||
|
||||
// A disabled owner cannot authenticate with their token.
|
||||
if err := auth.DisableUser(owner.ID, true); err != nil {
|
||||
t.Fatalf("DisableUser returned error: %v", err)
|
||||
}
|
||||
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
||||
t.Fatalf("disabled user token still resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) {
|
||||
auth := newTestAuthService(t)
|
||||
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
|
||||
unlimited := -1.0
|
||||
if err := auth.SetUserPolicy(user.ID, UserPolicy{MaxUploadMB: &unlimited, DailyUploadMB: &unlimited}); err != nil {
|
||||
t.Fatalf("SetUserPolicy rejected -1 unlimited upload limits: %v", err)
|
||||
}
|
||||
updated, err := auth.UserByID(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByID returned error: %v", err)
|
||||
}
|
||||
if updated.Policy.MaxUploadMB == nil || *updated.Policy.MaxUploadMB != -1 || updated.Policy.DailyUploadMB == nil || *updated.Policy.DailyUploadMB != -1 {
|
||||
t.Fatalf("unlimited policy was not persisted: %+v", updated.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestAuthService(t *testing.T) *AuthService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -169,13 +170,13 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
||||
}
|
||||
|
||||
func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
|
||||
if settings.AnonymousMaxUploadMB <= 0 {
|
||||
if settings.AnonymousMaxUploadMB == 0 {
|
||||
settings.AnonymousMaxUploadMB = s.defaults.AnonymousMaxUploadMB
|
||||
}
|
||||
if settings.AnonymousDailyUploadMB <= 0 {
|
||||
if settings.AnonymousDailyUploadMB == 0 {
|
||||
settings.AnonymousDailyUploadMB = s.defaults.AnonymousDailyUploadMB
|
||||
}
|
||||
if settings.UserDailyUploadMB <= 0 {
|
||||
if settings.UserDailyUploadMB == 0 {
|
||||
settings.UserDailyUploadMB = s.defaults.UserDailyUploadMB
|
||||
}
|
||||
if settings.DefaultUserStorageMB <= 0 {
|
||||
@@ -369,14 +370,14 @@ func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, err
|
||||
}
|
||||
|
||||
func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
||||
if settings.AnonymousMaxUploadMB <= 0 {
|
||||
return fmt.Errorf("anonymous max upload must be positive")
|
||||
if settings.AnonymousMaxUploadMB < 0 && settings.AnonymousMaxUploadMB != -1 || settings.AnonymousMaxUploadMB == 0 {
|
||||
return fmt.Errorf("anonymous max upload must be positive or -1 for unlimited")
|
||||
}
|
||||
if settings.AnonymousDailyUploadMB <= 0 {
|
||||
return fmt.Errorf("anonymous daily upload must be positive")
|
||||
if settings.AnonymousDailyUploadMB < 0 && settings.AnonymousDailyUploadMB != -1 || settings.AnonymousDailyUploadMB == 0 {
|
||||
return fmt.Errorf("anonymous daily upload must be positive or -1 for unlimited")
|
||||
}
|
||||
if settings.UserDailyUploadMB <= 0 {
|
||||
return fmt.Errorf("user daily upload must be positive")
|
||||
if settings.UserDailyUploadMB < 0 && settings.UserDailyUploadMB != -1 || settings.UserDailyUploadMB == 0 {
|
||||
return fmt.Errorf("user daily upload must be positive or -1 for unlimited")
|
||||
}
|
||||
if settings.DefaultUserStorageMB <= 0 {
|
||||
return fmt.Errorf("default user storage must be positive")
|
||||
@@ -421,6 +422,32 @@ func ParseMegabytesValue(value string) (float64, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func ParseMegabytesLimitValue(value string) (float64, error) {
|
||||
parsed, err := parseMegabytesNumber(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if parsed == -1 {
|
||||
return -1, nil
|
||||
}
|
||||
if parsed <= 0 {
|
||||
return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseMegabytesNumber(value string) (float64, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, fmt.Errorf("megabyte value is required")
|
||||
}
|
||||
value = strings.TrimSuffix(value, "MB")
|
||||
value = strings.TrimSuffix(value, "Mb")
|
||||
value = strings.TrimSuffix(value, "mb")
|
||||
value = strings.TrimSpace(value)
|
||||
return strconv.ParseFloat(value, 64)
|
||||
}
|
||||
|
||||
func MegabytesToBytes(value float64) int64 {
|
||||
return int64(value * 1024 * 1024)
|
||||
}
|
||||
@@ -431,10 +458,14 @@ func GigabytesToBytes(value float64) int64 {
|
||||
|
||||
func FormatMegabytesFromBytes(value int64) string {
|
||||
mb := float64(value) / 1024 / 1024
|
||||
mb = math.Round(mb*100) / 100
|
||||
return FormatMegabytesLabel(mb)
|
||||
}
|
||||
|
||||
func FormatMegabytesLabel(value float64) string {
|
||||
if value < 0 {
|
||||
return "unlimited"
|
||||
}
|
||||
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,30 @@ func TestSettingsRejectInvalidMegabytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) {
|
||||
settings := newTestSettingsService(t)
|
||||
policy, err := settings.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
policy.AnonymousMaxUploadMB = -1
|
||||
policy.AnonymousDailyUploadMB = -1
|
||||
policy.UserDailyUploadMB = -1
|
||||
if err := settings.UpdateUploadPolicy(policy); err != nil {
|
||||
t.Fatalf("UpdateUploadPolicy rejected -1 unlimited upload limits: %v", err)
|
||||
}
|
||||
next, err := settings.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
if next.AnonymousMaxUploadMB != -1 || next.AnonymousDailyUploadMB != -1 || next.UserDailyUploadMB != -1 {
|
||||
t.Fatalf("unlimited upload limits were not persisted: %+v", next)
|
||||
}
|
||||
if got := FormatMegabytesLabel(-1); got != "unlimited" {
|
||||
t.Fatalf("FormatMegabytesLabel(-1) = %q, want unlimited", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyUsageAndCleanup(t *testing.T) {
|
||||
settings := newTestSettingsService(t)
|
||||
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hirochachacha/go-smb2"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/pkg/sftp"
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
var storageBackendsBucket = []byte("storage_backends")
|
||||
var storageBackendTestStatusBucket = []byte("storage_backend_test_status")
|
||||
|
||||
const (
|
||||
StorageBackendLocal = "local"
|
||||
@@ -92,10 +82,12 @@ type StorageBackendConfig struct {
|
||||
}
|
||||
|
||||
type StorageBackendView struct {
|
||||
Config StorageBackendConfig
|
||||
UsageBytes int64
|
||||
UsageLabel string
|
||||
InUse bool
|
||||
Config StorageBackendConfig
|
||||
UsageBytes int64
|
||||
UsageLabel string
|
||||
InUse bool
|
||||
SpeedTests []StorageSpeedTest
|
||||
CanSpeedTest bool
|
||||
}
|
||||
|
||||
type StorageService struct {
|
||||
@@ -110,7 +102,13 @@ func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) {
|
||||
}
|
||||
service := &StorageService{db: db, localFilesDir: filesDir}
|
||||
err := db.Update(func(tx *bbolt.Tx) error {
|
||||
_, err := tx.CreateBucketIfNotExists(storageBackendsBucket)
|
||||
if _, err := tx.CreateBucketIfNotExists(storageBackendsBucket); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.CreateBucketIfNotExists(storageBackendTestStatusBucket); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.CreateBucketIfNotExists(storageSpeedTestsBucket)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -137,7 +135,9 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) {
|
||||
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == StorageBackendLocal {
|
||||
return s.localConfig(), nil
|
||||
cfg := s.localConfig()
|
||||
s.applyStoredTestStatus(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
var cfg StorageBackendConfig
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
@@ -178,18 +178,13 @@ func (s *StorageService) ListBackendConfigs() ([]StorageBackendConfig, error) {
|
||||
}
|
||||
|
||||
func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||
return s.CreateBackend(input)
|
||||
}
|
||||
|
||||
func (s *StorageService) CreateBackend(input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||
input.ID = randomID(10)
|
||||
input.Provider = normalizeStorageProvider(input.Provider)
|
||||
switch input.Provider {
|
||||
case StorageProviderSFTP:
|
||||
input.Type = StorageBackendSFTP
|
||||
case StorageProviderSMB:
|
||||
input.Type = StorageBackendSMB
|
||||
case StorageProviderWebDAV:
|
||||
input.Type = StorageBackendWebDAV
|
||||
default:
|
||||
input.Type = StorageBackendS3
|
||||
}
|
||||
input.Type = storageTypeForProvider(input.Provider)
|
||||
if err := normalizeStorageBackendConfig(&input, true); err != nil {
|
||||
return StorageBackendConfig{}, err
|
||||
}
|
||||
@@ -204,6 +199,10 @@ func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBac
|
||||
}
|
||||
|
||||
func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||
return s.UpdateBackend(id, input)
|
||||
}
|
||||
|
||||
func (s *StorageService) UpdateBackend(id string, input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||
current, err := s.BackendConfig(id)
|
||||
if err != nil {
|
||||
return StorageBackendConfig{}, err
|
||||
@@ -211,18 +210,19 @@ func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig)
|
||||
if current.ID == StorageBackendLocal {
|
||||
return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited")
|
||||
}
|
||||
current.Provider = canonicalStorageProvider(current)
|
||||
current.Type = storageTypeForProvider(current.Provider)
|
||||
|
||||
input.ID = current.ID
|
||||
input.Type = current.Type
|
||||
input.Provider = normalizeStorageProvider(input.Provider)
|
||||
switch input.Provider {
|
||||
case StorageProviderSFTP:
|
||||
input.Type = StorageBackendSFTP
|
||||
case StorageProviderSMB:
|
||||
input.Type = StorageBackendSMB
|
||||
case StorageProviderWebDAV:
|
||||
input.Type = StorageBackendWebDAV
|
||||
default:
|
||||
input.Type = StorageBackendS3
|
||||
requestedProvider := normalizeStorageProvider(input.Provider)
|
||||
requestedType := storageTypeForProvider(requestedProvider)
|
||||
if input.Type != "" && input.Type != requestedType {
|
||||
return StorageBackendConfig{}, fmt.Errorf("storage type cannot be changed after creation")
|
||||
}
|
||||
input.Provider = requestedProvider
|
||||
input.Type = requestedType
|
||||
if input.Provider != current.Provider || input.Type != current.Type {
|
||||
return StorageBackendConfig{}, fmt.Errorf("storage provider cannot be changed after creation")
|
||||
}
|
||||
if strings.TrimSpace(input.SecretKey) == "" {
|
||||
input.SecretKey = current.SecretKey
|
||||
@@ -385,10 +385,56 @@ func (s *StorageService) TestBackend(id string) (StorageBackendConfig, error) {
|
||||
}
|
||||
if cfg.ID != StorageBackendLocal {
|
||||
_ = s.SaveBackendConfig(cfg)
|
||||
} else {
|
||||
_ = s.saveBackendTestStatus(cfg)
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func (s *StorageService) applyStoredTestStatus(cfg *StorageBackendConfig) {
|
||||
_ = s.db.View(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(storageBackendTestStatusBucket)
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
data := bucket.Get([]byte(cfg.ID))
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
var status struct {
|
||||
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
|
||||
LastTestError string `json:"lastTestError,omitempty"`
|
||||
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &status); err != nil {
|
||||
return nil
|
||||
}
|
||||
cfg.LastTestedAt = status.LastTestedAt
|
||||
cfg.LastTestError = status.LastTestError
|
||||
cfg.LastTestSuccess = status.LastTestSuccess
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StorageService) saveBackendTestStatus(cfg StorageBackendConfig) error {
|
||||
status := struct {
|
||||
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
|
||||
LastTestError string `json:"lastTestError,omitempty"`
|
||||
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
|
||||
}{
|
||||
LastTestedAt: cfg.LastTestedAt,
|
||||
LastTestError: cfg.LastTestError,
|
||||
LastTestSuccess: cfg.LastTestSuccess,
|
||||
}
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(storageBackendTestStatusBucket).Put([]byte(cfg.ID), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBackend, error) {
|
||||
switch cfg.Type {
|
||||
case StorageBackendLocal:
|
||||
@@ -400,7 +446,7 @@ func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBac
|
||||
case StorageBackendSMB:
|
||||
return smbStorageBackend{cfg: cfg}, nil
|
||||
case StorageBackendWebDAV:
|
||||
return webDAVStorageBackend{cfg: cfg, client: http.DefaultClient}, nil
|
||||
return newWebDAVStorageBackend(cfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported storage backend type %q", cfg.Type)
|
||||
}
|
||||
@@ -420,758 +466,6 @@ func (s *StorageService) localConfig() StorageBackendConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type localStorageBackend struct {
|
||||
id string
|
||||
root string
|
||||
}
|
||||
|
||||
func (b localStorageBackend) ID() string { return b.id }
|
||||
func (b localStorageBackend) Type() string { return StorageBackendLocal }
|
||||
|
||||
func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
source, err := os.Open(path)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Delete(_ context.Context, key string) error {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error {
|
||||
path, err := b.path(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Usage(_ context.Context) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b localStorageBackend) path(key string) (string, error) {
|
||||
key = filepath.Clean(strings.TrimPrefix(key, "/"))
|
||||
if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) {
|
||||
return "", fmt.Errorf("invalid storage key")
|
||||
}
|
||||
path := filepath.Join(b.root, key)
|
||||
root, err := filepath.Abs(b.root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid storage key")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
type s3StorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) {
|
||||
endpoint := normalizeS3Endpoint(cfg.Endpoint)
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
BucketLookup: s3BucketLookup(cfg.PathStyle),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s3StorageBackend{cfg: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
|
||||
|
||||
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
info, err := object.Stat()
|
||||
if err != nil {
|
||||
object.Close()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/"
|
||||
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
|
||||
for object := range objects {
|
||||
if object.Err != nil {
|
||||
return object.Err
|
||||
}
|
||||
if err := b.Delete(ctx, object.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
var total int64
|
||||
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
||||
if object.Err != nil {
|
||||
return 0, object.Err
|
||||
}
|
||||
total += object.Size
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Test(ctx context.Context) error {
|
||||
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
|
||||
}
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
type sftpStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b sftpStorageBackend) Type() string { return StorageBackendSFTP }
|
||||
|
||||
func (b sftpStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
if err := client.MkdirAll(path.Dir(remotePath)); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := client.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
source, err := client.Open(remotePath)
|
||||
if err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(prefix)
|
||||
if err := client.RemoveDirectory(remotePath); err == nil || os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
walker := client.Walk(remotePath)
|
||||
paths := make([]string, 0)
|
||||
for walker.Step() {
|
||||
if walker.Err() != nil {
|
||||
return walker.Err()
|
||||
}
|
||||
paths = append(paths, walker.Path())
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
|
||||
for _, item := range paths {
|
||||
if err := client.Remove(item); err != nil {
|
||||
_ = client.RemoveDirectory(item)
|
||||
}
|
||||
}
|
||||
_ = client.RemoveDirectory(remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
walker := client.Walk(cleanRemoteRoot(b.cfg.RemotePath))
|
||||
for walker.Step() {
|
||||
if walker.Err() != nil {
|
||||
return 0, walker.Err()
|
||||
}
|
||||
info := walker.Stat()
|
||||
if info != nil && !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) client() (*sftp.Client, func(), error) {
|
||||
auth := make([]ssh.AuthMethod, 0, 2)
|
||||
if b.cfg.PrivateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(b.cfg.PrivateKey))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if b.cfg.Password != "" {
|
||||
auth = append(auth, ssh.Password(b.cfg.Password))
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, nil, fmt.Errorf("sftp password or private key is required")
|
||||
}
|
||||
hostKeyCallback, err := b.hostKeyCallback()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sshClient, err := ssh.Dial("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), &ssh.ClientConfig{
|
||||
User: b.cfg.Username,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
sshClient.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return client, func() {
|
||||
client.Close()
|
||||
sshClient.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) hostKeyCallback() (ssh.HostKeyCallback, error) {
|
||||
if strings.TrimSpace(b.cfg.HostKey) == "" {
|
||||
return ssh.InsecureIgnoreHostKey(), nil
|
||||
}
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(b.cfg.HostKey)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid sftp host public key: %w", err)
|
||||
}
|
||||
return ssh.FixedHostKey(key), nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) remotePath(key string) string {
|
||||
return path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
|
||||
}
|
||||
|
||||
type joinedReadCloser struct {
|
||||
io.ReadCloser
|
||||
close func()
|
||||
}
|
||||
|
||||
func closeWith(source io.ReadCloser, close func()) io.ReadCloser {
|
||||
return joinedReadCloser{ReadCloser: source, close: close}
|
||||
}
|
||||
|
||||
func (c joinedReadCloser) Close() error {
|
||||
err := c.ReadCloser.Close()
|
||||
c.close()
|
||||
return err
|
||||
}
|
||||
|
||||
type smbStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b smbStorageBackend) Type() string { return StorageBackendSMB }
|
||||
|
||||
func (b smbStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
if err := share.MkdirAll(path.Dir(remotePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := share.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
source, err := share.Open(b.remotePath(key))
|
||||
if err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := share.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = share.RemoveAll(b.remotePath(prefix))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return smbUsage(share, cleanRemoteRoot(b.cfg.RemotePath))
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) share() (*smb2.Share, func(), error) {
|
||||
conn, err := net.DialTimeout("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dialer := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: b.cfg.Username,
|
||||
Password: b.cfg.Password,
|
||||
Domain: b.cfg.Domain,
|
||||
},
|
||||
}
|
||||
session, err := dialer.Dial(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
share, err := session.Mount(b.cfg.Share)
|
||||
if err != nil {
|
||||
session.Logoff()
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return share, func() {
|
||||
share.Umount()
|
||||
session.Logoff()
|
||||
conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) remotePath(key string) string {
|
||||
return strings.TrimPrefix(path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)), "/")
|
||||
}
|
||||
|
||||
func smbUsage(share *smb2.Share, root string) (int64, error) {
|
||||
root = strings.TrimPrefix(root, "/")
|
||||
entries, err := share.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, entry := range entries {
|
||||
item := path.Join(root, entry.Name())
|
||||
if entry.IsDir() {
|
||||
size, err := smbUsage(share, item)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += size
|
||||
continue
|
||||
}
|
||||
total += entry.Size()
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
type webDAVStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b webDAVStorageBackend) Type() string { return StorageBackendWebDAV }
|
||||
|
||||
func (b webDAVStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, contentType string) error {
|
||||
if err := b.mkcolParents(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := b.request(ctx, http.MethodPut, key, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("webdav put failed: %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
request, err := b.request(ctx, http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
response.Body.Close()
|
||||
return StorageObject{}, fmt.Errorf("webdav get failed: %s", response.Status)
|
||||
}
|
||||
modTime, _ := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified"))
|
||||
return StorageObject{Key: key, Size: response.ContentLength, ContentType: response.Header.Get("Content-Type"), ModTime: modTime, Body: response.Body}, nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.deletePath(ctx, key)
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
return b.deletePath(ctx, strings.TrimSuffix(prefix, "/")+"/")
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
request, err := b.request(ctx, "PROPFIND", "", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Depth", "infinity")
|
||||
request.Header.Set("Content-Type", "application/xml")
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("webdav usage failed: %s", response.Status)
|
||||
}
|
||||
var multi webDAVMultiStatus
|
||||
if err := xml.NewDecoder(response.Body).Decode(&multi); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, item := range multi.Responses {
|
||||
if item.PropStat.Prop.ResourceType.Collection != nil {
|
||||
continue
|
||||
}
|
||||
total += item.PropStat.Prop.ContentLength
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) deletePath(ctx context.Context, key string) error {
|
||||
request, err := b.request(ctx, http.MethodDelete, key, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("webdav delete failed: %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) mkcolParents(ctx context.Context, key string) error {
|
||||
dir := path.Dir(cleanObjectKey(key))
|
||||
if dir == "." || dir == "/" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(dir, "/"), "/")
|
||||
current := ""
|
||||
for _, part := range parts {
|
||||
current = path.Join(current, part)
|
||||
request, err := b.request(ctx, "MKCOL", strings.TrimSuffix(current, "/")+"/", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed && response.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("webdav mkcol failed: %s", response.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) request(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
|
||||
endpoint := strings.TrimRight(b.cfg.Endpoint, "/")
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("webdav url is required")
|
||||
}
|
||||
remote := path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
|
||||
if strings.HasSuffix(key, "/") && !strings.HasSuffix(remote, "/") {
|
||||
remote += "/"
|
||||
}
|
||||
target := endpoint + "/" + strings.TrimLeft(remote, "/")
|
||||
request, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.cfg.Username != "" || b.cfg.Password != "" {
|
||||
request.SetBasicAuth(b.cfg.Username, b.cfg.Password)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
type webDAVMultiStatus struct {
|
||||
Responses []webDAVResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type webDAVResponse struct {
|
||||
PropStat webDAVPropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type webDAVPropStat struct {
|
||||
Prop webDAVProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type webDAVProp struct {
|
||||
ContentLength int64 `xml:"getcontentlength"`
|
||||
ResourceType webDAVResourceType `xml:"resourcetype"`
|
||||
}
|
||||
|
||||
type webDAVResourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
}
|
||||
|
||||
func s3BucketLookup(pathStyle bool) minio.BucketLookupType {
|
||||
if pathStyle {
|
||||
return minio.BucketLookupPath
|
||||
}
|
||||
return minio.BucketLookupAuto
|
||||
}
|
||||
|
||||
func normalizeS3Endpoint(endpoint string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
|
||||
}
|
||||
|
||||
func normalizeStorageProvider(provider string) string {
|
||||
switch strings.TrimSpace(provider) {
|
||||
case StorageProviderContabo:
|
||||
@@ -1187,6 +481,35 @@ func normalizeStorageProvider(provider string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalStorageProvider(cfg StorageBackendConfig) string {
|
||||
if cfg.Provider != "" && cfg.Provider != StorageBackendLocal {
|
||||
return normalizeStorageProvider(cfg.Provider)
|
||||
}
|
||||
switch cfg.Type {
|
||||
case StorageBackendSFTP:
|
||||
return StorageProviderSFTP
|
||||
case StorageBackendSMB:
|
||||
return StorageProviderSMB
|
||||
case StorageBackendWebDAV:
|
||||
return StorageProviderWebDAV
|
||||
default:
|
||||
return StorageProviderS3
|
||||
}
|
||||
}
|
||||
|
||||
func storageTypeForProvider(provider string) string {
|
||||
switch normalizeStorageProvider(provider) {
|
||||
case StorageProviderSFTP:
|
||||
return StorageBackendSFTP
|
||||
case StorageProviderSMB:
|
||||
return StorageBackendSMB
|
||||
case StorageProviderWebDAV:
|
||||
return StorageBackendWebDAV
|
||||
default:
|
||||
return StorageBackendS3
|
||||
}
|
||||
}
|
||||
|
||||
func cleanObjectKey(key string) string {
|
||||
return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./")
|
||||
}
|
||||
|
||||
124
backend/libs/services/storage_local.go
Normal file
124
backend/libs/services/storage_local.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type localStorageBackend struct {
|
||||
id string
|
||||
root string
|
||||
}
|
||||
|
||||
func (b localStorageBackend) ID() string { return b.id }
|
||||
func (b localStorageBackend) Type() string { return StorageBackendLocal }
|
||||
|
||||
func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
source, err := os.Open(path)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Delete(_ context.Context, key string) error {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error {
|
||||
path, err := b.path(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Usage(_ context.Context) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b localStorageBackend) path(key string) (string, error) {
|
||||
key = filepath.Clean(strings.TrimPrefix(key, "/"))
|
||||
if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) {
|
||||
return "", fmt.Errorf("invalid storage key")
|
||||
}
|
||||
path := filepath.Join(b.root, key)
|
||||
root, err := filepath.Abs(b.root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid storage key")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
18
backend/libs/services/storage_readcloser.go
Normal file
18
backend/libs/services/storage_readcloser.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package services
|
||||
|
||||
import "io"
|
||||
|
||||
type joinedReadCloser struct {
|
||||
io.ReadCloser
|
||||
close func()
|
||||
}
|
||||
|
||||
func closeWith(source io.ReadCloser, close func()) io.ReadCloser {
|
||||
return joinedReadCloser{ReadCloser: source, close: close}
|
||||
}
|
||||
|
||||
func (c joinedReadCloser) Close() error {
|
||||
err := c.ReadCloser.Close()
|
||||
c.close()
|
||||
return err
|
||||
}
|
||||
113
backend/libs/services/storage_s3.go
Normal file
113
backend/libs/services/storage_s3.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type s3StorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) {
|
||||
endpoint := normalizeS3Endpoint(cfg.Endpoint)
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
BucketLookup: s3BucketLookup(cfg.PathStyle),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s3StorageBackend{cfg: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
|
||||
|
||||
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
info, err := object.Stat()
|
||||
if err != nil {
|
||||
object.Close()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/"
|
||||
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
|
||||
for object := range objects {
|
||||
if object.Err != nil {
|
||||
return object.Err
|
||||
}
|
||||
if err := b.Delete(ctx, object.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
var total int64
|
||||
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
||||
if object.Err != nil {
|
||||
return 0, object.Err
|
||||
}
|
||||
total += object.Size
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Test(ctx context.Context) error {
|
||||
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
|
||||
}
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func s3BucketLookup(pathStyle bool) minio.BucketLookupType {
|
||||
if pathStyle {
|
||||
return minio.BucketLookupPath
|
||||
}
|
||||
return minio.BucketLookupAuto
|
||||
}
|
||||
|
||||
func normalizeS3Endpoint(endpoint string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
|
||||
}
|
||||
200
backend/libs/services/storage_sftp.go
Normal file
200
backend/libs/services/storage_sftp.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type sftpStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b sftpStorageBackend) Type() string { return StorageBackendSFTP }
|
||||
|
||||
func (b sftpStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
if err := client.MkdirAll(path.Dir(remotePath)); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := client.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
source, err := client.Open(remotePath)
|
||||
if err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(prefix)
|
||||
if err := client.RemoveDirectory(remotePath); err == nil || os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
walker := client.Walk(remotePath)
|
||||
paths := make([]string, 0)
|
||||
for walker.Step() {
|
||||
if walker.Err() != nil {
|
||||
return walker.Err()
|
||||
}
|
||||
paths = append(paths, walker.Path())
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
|
||||
for _, item := range paths {
|
||||
if err := client.Remove(item); err != nil {
|
||||
_ = client.RemoveDirectory(item)
|
||||
}
|
||||
}
|
||||
_ = client.RemoveDirectory(remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
walker := client.Walk(cleanRemoteRoot(b.cfg.RemotePath))
|
||||
for walker.Step() {
|
||||
if walker.Err() != nil {
|
||||
return 0, walker.Err()
|
||||
}
|
||||
info := walker.Stat()
|
||||
if info != nil && !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) client() (*sftp.Client, func(), error) {
|
||||
auth := make([]ssh.AuthMethod, 0, 2)
|
||||
if b.cfg.PrivateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(b.cfg.PrivateKey))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if b.cfg.Password != "" {
|
||||
auth = append(auth, ssh.Password(b.cfg.Password))
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, nil, fmt.Errorf("sftp password or private key is required")
|
||||
}
|
||||
hostKeyCallback, err := b.hostKeyCallback()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sshClient, err := ssh.Dial("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), &ssh.ClientConfig{
|
||||
User: b.cfg.Username,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
sshClient.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return client, func() {
|
||||
client.Close()
|
||||
sshClient.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) hostKeyCallback() (ssh.HostKeyCallback, error) {
|
||||
if strings.TrimSpace(b.cfg.HostKey) == "" {
|
||||
return ssh.InsecureIgnoreHostKey(), nil
|
||||
}
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(b.cfg.HostKey)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid sftp host public key: %w", err)
|
||||
}
|
||||
return ssh.FixedHostKey(key), nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) remotePath(key string) string {
|
||||
return path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
|
||||
}
|
||||
176
backend/libs/services/storage_smb.go
Normal file
176
backend/libs/services/storage_smb.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hirochachacha/go-smb2"
|
||||
)
|
||||
|
||||
type smbStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b smbStorageBackend) Type() string { return StorageBackendSMB }
|
||||
|
||||
func (b smbStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
if err := share.MkdirAll(path.Dir(remotePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := share.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
source, err := share.Open(b.remotePath(key))
|
||||
if err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := share.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = share.RemoveAll(b.remotePath(prefix))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return smbUsage(share, cleanRemoteRoot(b.cfg.RemotePath))
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) share() (*smb2.Share, func(), error) {
|
||||
conn, err := net.DialTimeout("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dialer := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: b.cfg.Username,
|
||||
Password: b.cfg.Password,
|
||||
Domain: b.cfg.Domain,
|
||||
},
|
||||
}
|
||||
session, err := dialer.Dial(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
share, err := session.Mount(b.cfg.Share)
|
||||
if err != nil {
|
||||
session.Logoff()
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return share, func() {
|
||||
share.Umount()
|
||||
session.Logoff()
|
||||
conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) remotePath(key string) string {
|
||||
return strings.TrimPrefix(path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)), "/")
|
||||
}
|
||||
|
||||
func smbUsage(share *smb2.Share, root string) (int64, error) {
|
||||
root = strings.TrimPrefix(root, "/")
|
||||
entries, err := share.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, entry := range entries {
|
||||
item := path.Join(root, entry.Name())
|
||||
if entry.IsDir() {
|
||||
size, err := smbUsage(share, item)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += size
|
||||
continue
|
||||
}
|
||||
total += entry.Size()
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
424
backend/libs/services/storage_speed.go
Normal file
424
backend/libs/services/storage_speed.go
Normal 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
|
||||
}
|
||||
}
|
||||
193
backend/libs/services/storage_webdav.go
Normal file
193
backend/libs/services/storage_webdav.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type webDAVStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b webDAVStorageBackend) Type() string { return StorageBackendWebDAV }
|
||||
|
||||
func (b webDAVStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, contentType string) error {
|
||||
if err := b.mkcolParents(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := b.request(ctx, http.MethodPut, key, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("webdav put failed: %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
request, err := b.request(ctx, http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
response.Body.Close()
|
||||
return StorageObject{}, fmt.Errorf("webdav get failed: %s", response.Status)
|
||||
}
|
||||
modTime, _ := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified"))
|
||||
return StorageObject{Key: key, Size: response.ContentLength, ContentType: response.Header.Get("Content-Type"), ModTime: modTime, Body: response.Body}, nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.deletePath(ctx, key)
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
return b.deletePath(ctx, strings.TrimSuffix(prefix, "/")+"/")
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
request, err := b.request(ctx, "PROPFIND", "", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Depth", "infinity")
|
||||
request.Header.Set("Content-Type", "application/xml")
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("webdav usage failed: %s", response.Status)
|
||||
}
|
||||
var multi webDAVMultiStatus
|
||||
if err := xml.NewDecoder(response.Body).Decode(&multi); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, item := range multi.Responses {
|
||||
if item.PropStat.Prop.ResourceType.Collection != nil {
|
||||
continue
|
||||
}
|
||||
total += item.PropStat.Prop.ContentLength
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) deletePath(ctx context.Context, key string) error {
|
||||
request, err := b.request(ctx, http.MethodDelete, key, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("webdav delete failed: %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) mkcolParents(ctx context.Context, key string) error {
|
||||
dir := path.Dir(cleanObjectKey(key))
|
||||
if dir == "." || dir == "/" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(dir, "/"), "/")
|
||||
current := ""
|
||||
for _, part := range parts {
|
||||
current = path.Join(current, part)
|
||||
request, err := b.request(ctx, "MKCOL", strings.TrimSuffix(current, "/")+"/", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed && response.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("webdav mkcol failed: %s", response.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) request(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
|
||||
endpoint := strings.TrimRight(b.cfg.Endpoint, "/")
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("webdav url is required")
|
||||
}
|
||||
remote := path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
|
||||
if strings.HasSuffix(key, "/") && !strings.HasSuffix(remote, "/") {
|
||||
remote += "/"
|
||||
}
|
||||
target := endpoint + "/" + strings.TrimLeft(remote, "/")
|
||||
request, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.cfg.Username != "" || b.cfg.Password != "" {
|
||||
request.SetBasicAuth(b.cfg.Username, b.cfg.Password)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
type webDAVMultiStatus struct {
|
||||
Responses []webDAVResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type webDAVResponse struct {
|
||||
PropStat webDAVPropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type webDAVPropStat struct {
|
||||
Prop webDAVProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type webDAVProp struct {
|
||||
ContentLength int64 `xml:"getcontentlength"`
|
||||
ResourceType webDAVResourceType `xml:"resourcetype"`
|
||||
}
|
||||
|
||||
type webDAVResourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
}
|
||||
|
||||
func newWebDAVStorageBackend(cfg StorageBackendConfig) webDAVStorageBackend {
|
||||
return webDAVStorageBackend{cfg: cfg, client: http.DefaultClient}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type UploadService struct {
|
||||
|
||||
type UploadOptions struct {
|
||||
MaxDays int
|
||||
ExpiresInMinutes int
|
||||
MaxDownloads int
|
||||
Password string
|
||||
ObfuscateMetadata bool
|
||||
@@ -199,14 +200,20 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
opts.MaxDays = 7
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour)
|
||||
if opts.ExpiresInMinutes > 0 {
|
||||
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
|
||||
}
|
||||
|
||||
box := Box{
|
||||
ID: randomID(10),
|
||||
OwnerID: strings.TrimSpace(opts.OwnerID),
|
||||
CollectionID: strings.TrimSpace(opts.CollectionID),
|
||||
CreatorIP: strings.TrimSpace(opts.CreatorIP),
|
||||
StorageBackendID: normalizeBackendID(opts.StorageBackendID),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
MaxDownloads: opts.MaxDownloads,
|
||||
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
||||
Files: make([]File, 0, len(files)),
|
||||
|
||||
@@ -172,6 +172,157 @@ func TestSFTPStorageConfigValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageUpdateRejectsProviderMutation(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Name: "SFTP",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderS3,
|
||||
Name: "Mutated",
|
||||
Endpoint: "https://s3.example.test",
|
||||
Bucket: "bucket",
|
||||
AccessKey: "access",
|
||||
SecretKey: "secret",
|
||||
UseSSL: true,
|
||||
}); err == nil {
|
||||
t.Fatalf("UpdateBackend allowed provider mutation")
|
||||
}
|
||||
stored, err := service.Storage().BackendConfig(cfg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("BackendConfig returned error: %v", err)
|
||||
}
|
||||
if stored.Provider != StorageProviderSFTP || stored.Type != StorageBackendSFTP {
|
||||
t.Fatalf("provider/type mutated despite error: %+v", stored)
|
||||
}
|
||||
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Type: StorageBackendS3,
|
||||
Name: "Mutated",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
}); err == nil {
|
||||
t.Fatalf("UpdateBackend allowed type mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageUpdatePreservesSecretsWhenBlank(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Name: "SFTP",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
PrivateKey: "private-key",
|
||||
HostKey: "host-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Name: "SFTP renamed",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateBackend returned error: %v", err)
|
||||
}
|
||||
if updated.Password != "secret" || updated.PrivateKey != "private-key" || updated.HostKey != "host-key" {
|
||||
t.Fatalf("blank secret fields were not preserved: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContaboUpdateKeepsTLSAndPathStyleLocked(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
|
||||
Provider: StorageProviderContabo,
|
||||
Name: "Contabo",
|
||||
Endpoint: "https://eu2.contabostorage.com",
|
||||
Bucket: "My Main Bucket",
|
||||
AccessKey: "access",
|
||||
SecretKey: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderContabo,
|
||||
Name: "Contabo",
|
||||
Endpoint: "https://eu2.contabostorage.com",
|
||||
Bucket: "My Main Bucket",
|
||||
AccessKey: "access",
|
||||
SecretKey: "secret",
|
||||
UseSSL: false,
|
||||
PathStyle: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateBackend returned error: %v", err)
|
||||
}
|
||||
if !updated.UseSSL || !updated.PathStyle {
|
||||
t.Fatalf("contabo TLS/path-style were not locked: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageSpeedTestRequiresConnectionAndRuns(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
if _, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall); err == nil {
|
||||
t.Fatalf("StartSpeedTest allowed speed test before connection test")
|
||||
}
|
||||
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
|
||||
t.Fatalf("TestBackend local returned error: %v", err)
|
||||
}
|
||||
test, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSpeedTest returned error: %v", err)
|
||||
}
|
||||
service.Storage().RunSpeedTest(testContext(), test.ID)
|
||||
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpeedTests returned error: %v", err)
|
||||
}
|
||||
if len(tests) != 1 {
|
||||
t.Fatalf("speed tests len = %d, want 1", len(tests))
|
||||
}
|
||||
got := tests[0]
|
||||
if got.Status != StorageSpeedStatusDone || got.ProgressPercent != 100 || got.Stage != "complete" || got.BytesWritten == 0 || got.BytesRead == 0 || got.FilesWritten == 0 {
|
||||
t.Fatalf("speed test did not complete with metrics: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomStorageSpeedTestUsesRequestedFiles(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
|
||||
t.Fatalf("TestBackend local returned error: %v", err)
|
||||
}
|
||||
test, err := service.Storage().StartSpeedTestWithOptions(StorageBackendLocal, StorageSpeedTestOptions{
|
||||
Mode: StorageSpeedModeCustom,
|
||||
CustomFileCount: 3,
|
||||
CustomFileSizeMB: 0.001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartSpeedTestWithOptions returned error: %v", err)
|
||||
}
|
||||
service.Storage().RunSpeedTest(testContext(), test.ID)
|
||||
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpeedTests returned error: %v", err)
|
||||
}
|
||||
got := tests[0]
|
||||
if got.Mode != StorageSpeedModeCustom || got.CustomFileCount != 3 || got.CustomFileSizeMB != 0.001 || got.FilesWritten != 3 || got.Status != StorageSpeedStatusDone {
|
||||
t.Fatalf("custom speed test did not use requested files: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
||||
|
||||
@@ -8,13 +8,15 @@ import (
|
||||
)
|
||||
|
||||
type Renderer struct {
|
||||
templates map[string]*template.Template
|
||||
appName string
|
||||
baseURL string
|
||||
templates map[string]*template.Template
|
||||
appName string
|
||||
appVersion string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
AppName string
|
||||
AppVersion string
|
||||
BaseURL string
|
||||
Title string
|
||||
Description string
|
||||
@@ -25,7 +27,7 @@ type PageData struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
||||
func NewRenderer(templateDir, appName, appVersion, baseURL string) (*Renderer, error) {
|
||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,14 +58,16 @@ func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
||||
}
|
||||
|
||||
return &Renderer{
|
||||
templates: templates,
|
||||
appName: appName,
|
||||
baseURL: baseURL,
|
||||
templates: templates,
|
||||
appName: appName,
|
||||
appVersion: appVersion,
|
||||
baseURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
|
||||
data.AppName = r.appName
|
||||
data.AppVersion = r.appVersion
|
||||
data.BaseURL = r.baseURL
|
||||
data.CurrentYear = time.Now().Year()
|
||||
|
||||
|
||||
BIN
backend/static/backgrounds/stars1.gif
Normal file
BIN
backend/static/backgrounds/stars1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
525
backend/static/css/00-base.css
Normal file
525
backend/static/css/00-base.css
Normal file
@@ -0,0 +1,525 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--background: #0b0b16;
|
||||
--foreground: #f5f3ff;
|
||||
--card: #15132b;
|
||||
--card-foreground: #f5f3ff;
|
||||
--muted: #1e1b3a;
|
||||
--muted-foreground: #a8a4cf;
|
||||
--accent: #2a2550;
|
||||
--accent-foreground: #f5f3ff;
|
||||
--border: rgba(168, 150, 255, 0.16);
|
||||
--input: rgba(168, 150, 255, 0.22);
|
||||
--primary: #8b5cf6;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-hover: #7c3aed;
|
||||
--ring: #a78bfa;
|
||||
--success: #5eead4;
|
||||
--danger: #fb7185;
|
||||
--radius: 0.875rem;
|
||||
--shadow: 0 24px 70px rgba(8, 4, 32, 0.6);
|
||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--header-bg: rgba(11, 11, 22, 0.68);
|
||||
--body-bg:
|
||||
radial-gradient(circle at 50% -10%, rgba(139, 92, 246, 0.18), transparent 34rem),
|
||||
linear-gradient(180deg, #0b0b16 0%, #0a0918 100%);
|
||||
--surface-1: rgba(139, 92, 246, 0.07);
|
||||
--surface-1-hover: rgba(139, 92, 246, 0.14);
|
||||
--surface-2: rgba(139, 92, 246, 0.05);
|
||||
}
|
||||
|
||||
:root[data-theme="classic"] {
|
||||
color-scheme: dark;
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: #18181b;
|
||||
--card-foreground: #fafafa;
|
||||
--muted: #27272a;
|
||||
--muted-foreground: #a1a1aa;
|
||||
--accent: #27272a;
|
||||
--accent-foreground: #fafafa;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--input: rgba(255, 255, 255, 0.15);
|
||||
--primary: #f4f4f5;
|
||||
--primary-foreground: #18181b;
|
||||
--primary-hover: #e4e4e7;
|
||||
--ring: #71717a;
|
||||
--success: #86efac;
|
||||
--danger: #fca5a5;
|
||||
--radius: 0.625rem;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--header-bg: rgba(9, 9, 11, 0.84);
|
||||
--body-bg:
|
||||
radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem),
|
||||
linear-gradient(180deg, #09090b 0%, #0f0f12 100%);
|
||||
--surface-1: rgba(39, 39, 42, 0.42);
|
||||
--surface-1-hover: rgba(39, 39, 42, 0.68);
|
||||
--surface-2: rgba(39, 39, 42, 0.28);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] {
|
||||
color-scheme: light;
|
||||
--background: #ffffff;
|
||||
--foreground: #000000;
|
||||
--card: #c0c0c0;
|
||||
--card-foreground: #000000;
|
||||
--muted: #c0c0c0;
|
||||
--muted-foreground: #404040;
|
||||
--accent: #000078;
|
||||
--accent-foreground: #ffffff;
|
||||
--border: #000000;
|
||||
--input: #000000;
|
||||
--primary: #000078;
|
||||
--primary-foreground: #ffffff;
|
||||
--primary-hover: #0f80cd;
|
||||
--ring: #000078;
|
||||
--success: #008000;
|
||||
--danger: #c00000;
|
||||
--radius: 0rem;
|
||||
--shadow:
|
||||
inset -1px -1px 0 #404040,
|
||||
inset 1px 1px 0 #ffffff,
|
||||
inset -2px -2px 0 #808080,
|
||||
inset 2px 2px 0 #dfdfdf;
|
||||
--font-sans: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||
--header-bg: #c0c0c0;
|
||||
--body-bg: #000000;
|
||||
--surface-1: #ffffff;
|
||||
--surface-1-hover: #fffff0;
|
||||
--surface-2: #c0c0c0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: -4rem;
|
||||
z-index: 10;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
min-height: 3.5rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.nav-links,
|
||||
.footer-links,
|
||||
.inline-form {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
font-size: 2rem;
|
||||
line-height: 1.12;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.download-subtitle,
|
||||
.panel-header p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-view {
|
||||
width: min(28rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.muted-copy,
|
||||
.auth-alt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.stack-form label,
|
||||
.inline-controls label,
|
||||
.collection-create label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
color: #fca5a5;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.checkbox-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.checkbox-field input {
|
||||
width: 1rem;
|
||||
min-height: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-field span {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
label span {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-footer,
|
||||
.result-header {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-footer p,
|
||||
#result-meta {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.55);
|
||||
}
|
||||
|
||||
.button-wide {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
margin: 0.8rem 0 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
padding: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
pre code {
|
||||
display: block;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.theme-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.theme-picker > span {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.theme-picker select {
|
||||
width: auto;
|
||||
min-height: 1.9rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 1rem 0 0;
|
||||
color: #fecaca;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.button-sm {
|
||||
min-height: 1.85rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Badge variants */
|
||||
.badge-active {
|
||||
background: rgba(134, 239, 172, 0.12);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.badge-disabled {
|
||||
background: rgba(252, 165, 165, 0.1);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.badge-expired {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Nav username indicator in header */
|
||||
.nav-username {
|
||||
max-width: 8rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
281
backend/static/css/10-layout.css
Normal file
281
backend/static/css/10-layout.css
Normal file
@@ -0,0 +1,281 @@
|
||||
.app-shell {
|
||||
width: min(86rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
align-self: start;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(24, 24, 27, 0.58);
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.62rem 0.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-link:hover,
|
||||
.sidebar-link.is-active {
|
||||
border-color: var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.admin-shell .app-sidebar {
|
||||
border-color: rgba(125, 211, 252, 0.28);
|
||||
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
|
||||
}
|
||||
|
||||
.admin-shell .sidebar-link.is-active {
|
||||
border-color: rgba(125, 211, 252, 0.42);
|
||||
background: rgba(14, 116, 144, 0.24);
|
||||
}
|
||||
|
||||
.admin-shell .kicker {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
display: grid;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.sidebar-logout .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collection-create {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.compact-upload .drop-zone {
|
||||
min-height: 11rem;
|
||||
}
|
||||
|
||||
.dashboard-options {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.collection-tabs,
|
||||
.inline-controls {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.inline-controls input,
|
||||
.inline-controls select {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.compact-input {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-form-narrow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.settings-form .checkbox-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-form button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
|
||||
/* Tab navigation */
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 120ms, color 120ms, border-color 120ms;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tab.is-active {
|
||||
border-color: var(--border);
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
/* Sidebar structure */
|
||||
.sidebar-sep {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Collection create dropdown */
|
||||
.new-collection-drop {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.new-collection-drop > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-collection-drop > summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.new-collection-body {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.5rem);
|
||||
z-index: 10;
|
||||
width: 15rem;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--card) 97%, #000);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.new-collection-body label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Copyable URL field */
|
||||
.copy-field {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copy-field input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Settings sections */
|
||||
.settings-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.settings-section .checkbox-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-section label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Quota form in admin users table */
|
||||
.quota-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quota-form input {
|
||||
width: 6.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
214
backend/static/css/15-revamp.css
Normal file
214
backend/static/css/15-revamp.css
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Revamp ("Aurora glass") flourishes.
|
||||
*
|
||||
* These rules only apply to the default/revamp theme. They are scoped to
|
||||
* :root:not([data-theme="classic"]):not([data-theme="retro"]) so they cover both the explicit
|
||||
* data-theme="revamp" attribute AND the no-JS default (no attribute), while
|
||||
* never touching the classic theme. Token colours live in 00-base.css; this
|
||||
* file adds the things a flat token swap can't: the animated aurora backdrop,
|
||||
* frosted glass, gradient accents, glow and motion.
|
||||
*/
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Animated aurora backdrop ------------------------------------------------ */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: -20vmax;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(38vmax 38vmax at 18% 12%, rgba(99, 102, 241, 0.38), transparent 60%),
|
||||
radial-gradient(34vmax 34vmax at 82% 18%, rgba(34, 211, 238, 0.26), transparent 60%),
|
||||
radial-gradient(40vmax 40vmax at 70% 88%, rgba(139, 92, 246, 0.34), transparent 62%),
|
||||
radial-gradient(30vmax 30vmax at 12% 82%, rgba(236, 72, 153, 0.22), transparent 60%);
|
||||
filter: blur(8px) saturate(125%);
|
||||
animation: aurora-drift 26s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
/* faint grain/vignette to keep the glow from washing out text */
|
||||
background: radial-gradient(circle at 50% 40%, transparent 0, rgba(10, 9, 24, 0.55) 78%);
|
||||
}
|
||||
|
||||
@keyframes aurora-drift {
|
||||
0% {
|
||||
transform: translate3d(-4%, -2%, 0) rotate(0deg) scale(1.05);
|
||||
}
|
||||
50% {
|
||||
transform: translate3d(3%, 2%, 0) rotate(8deg) scale(1.12);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(2%, -3%, 0) rotate(-6deg) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Frosted glass cards ----------------------------------------------------- */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .card {
|
||||
background: linear-gradient(
|
||||
155deg,
|
||||
color-mix(in srgb, var(--card) 78%, transparent),
|
||||
color-mix(in srgb, var(--card) 92%, transparent)
|
||||
);
|
||||
border-color: rgba(168, 150, 255, 0.18);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
}
|
||||
|
||||
/* Sticky header gets the same glassy treatment */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .site-header {
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
}
|
||||
|
||||
/* Brand mark glows */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .brand-mark {
|
||||
background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45);
|
||||
}
|
||||
|
||||
/* Headings get a soft gradient sheen */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) h1 {
|
||||
background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Gradient primary buttons ------------------------------------------------ */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button.is-active {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.38);
|
||||
transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:hover {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Outline / ghost buttons get a subtle lift on hover */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost {
|
||||
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline:hover,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost:hover {
|
||||
border-color: rgba(168, 150, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Glow focus rings -------------------------------------------------------- */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) :focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55);
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) input:focus,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) select:focus {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
|
||||
}
|
||||
|
||||
/* Drop zone: animated, glowing -------------------------------------------- */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone {
|
||||
border-color: rgba(168, 150, 255, 0.3);
|
||||
background:
|
||||
radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%),
|
||||
var(--surface-1);
|
||||
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone:hover,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging {
|
||||
border-color: #a78bfa;
|
||||
box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-icon {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging .drop-icon {
|
||||
animation: drop-bounce 700ms ease infinite;
|
||||
}
|
||||
|
||||
@keyframes drop-bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
/* Badges pick up a tinted glass look */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .badge {
|
||||
background: rgba(139, 92, 246, 0.14);
|
||||
color: #d6ccff;
|
||||
border: 1px solid rgba(168, 150, 255, 0.22);
|
||||
}
|
||||
|
||||
/* File / result rows lift on hover */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .result-item {
|
||||
background: color-mix(in srgb, var(--card) 60%, transparent);
|
||||
border-color: rgba(168, 150, 255, 0.14);
|
||||
transition: border-color 140ms ease, transform 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item:hover {
|
||||
border-color: rgba(168, 150, 255, 0.34);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Thumbnails on the download page */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) .file-emblem {
|
||||
background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18));
|
||||
color: #d6ccff;
|
||||
border: 1px solid rgba(168, 150, 255, 0.22);
|
||||
}
|
||||
|
||||
/* Gentle entrance for primary content cards */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * {
|
||||
animation: rise-in 420ms ease both;
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
442
backend/static/css/16-retro.css
Normal file
442
backend/static/css/16-retro.css
Normal file
@@ -0,0 +1,442 @@
|
||||
/*
|
||||
* "retro" theme flourishes — modelled on danlegt.com.
|
||||
*
|
||||
* Windows 98 chrome over a black pixel-star desktop, PixeloidSans pixel font,
|
||||
* crisp (non-antialiased, pixelated) rendering. Scoped entirely to
|
||||
* :root[data-theme="retro"] so it never touches the other themes.
|
||||
*
|
||||
* CSP-safe: external stylesheet + self-hosted fonts only (font-src 'self'),
|
||||
* no inline styles, no remote assets. The starfield is pure CSS so we don't
|
||||
* depend on img-src for a background gif.
|
||||
*/
|
||||
|
||||
/* Self-hosted pixel fonts (mirrored locally — GGBotNet PixeloidSans is free,
|
||||
PixelOperator is CC0). ------------------------------------------------- */
|
||||
@font-face {
|
||||
font-family: "PixeloidSans";
|
||||
src: url("/static/fonts/pixeloid_sans/PixeloidSans.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "PixeloidSans";
|
||||
src: url("/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "PixelOperatorMono";
|
||||
src: url("/static/fonts/pixel_operator/PixelOperatorMono.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "PixelOperatorMono";
|
||||
src: url("/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf") format("truetype");
|
||||
font-weight: bold;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Crisp, pixelated, non-smoothed rendering like the source site. */
|
||||
:root[data-theme="retro"] {
|
||||
font-smooth: never;
|
||||
-webkit-font-smoothing: none;
|
||||
-moz-osx-font-smoothing: unset;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] img,
|
||||
:root[data-theme="retro"] .thumb-link img,
|
||||
:root[data-theme="retro"] .preview-stage img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Square everything — Win98 had no rounded corners. */
|
||||
:root[data-theme="retro"] *,
|
||||
:root[data-theme="retro"] *::before,
|
||||
:root[data-theme="retro"] *::after {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Black desktop with the tiled starfield mirrored from danlegt.com. */
|
||||
:root[data-theme="retro"] body {
|
||||
background-color: #000000;
|
||||
background-image: url("/static/backgrounds/stars1.gif");
|
||||
background-repeat: repeat;
|
||||
background-attachment: fixed;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Selection + focus use the classic dotted/navy treatment. */
|
||||
:root[data-theme="retro"] ::selection {
|
||||
background: #000078;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] :focus-visible {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
/* Header: thin flat silver toolbar with a bottom bevel. */
|
||||
:root[data-theme="retro"] .site-header {
|
||||
background: #c0c0c0;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border-bottom: 2px solid #404040;
|
||||
box-shadow: inset 0 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .nav {
|
||||
min-height: 2.1rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .site-header .button {
|
||||
min-height: 1.6rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .brand {
|
||||
color: #000000;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .brand-mark {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
background: linear-gradient(90deg, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 0.75rem;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #6f6fff;
|
||||
}
|
||||
|
||||
/* Cards are raised silver windows with black text. */
|
||||
:root[data-theme="retro"] .card {
|
||||
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||
background-color: #c0c0c0;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Headings become Win98 active title bars. */
|
||||
:root[data-theme="retro"] h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin: -0.35rem -0.35rem 1rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Fake window control button on the right of every title bar. */
|
||||
:root[data-theme="retro"] h1::after {
|
||||
content: "✕";
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.15rem;
|
||||
height: 1rem;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Links: classic blue, underlined, purple when visited. */
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand) {
|
||||
color: #0000ee;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):visited {
|
||||
color: #551a8b;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):hover {
|
||||
color: #ee0000;
|
||||
}
|
||||
|
||||
/* Buttons: grey beveled chunks that press in when active. */
|
||||
:root[data-theme="retro"] .button,
|
||||
:root[data-theme="retro"] button {
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
font-weight: 700;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button:hover,
|
||||
:root[data-theme="retro"] button:hover {
|
||||
background: #d4d0c8;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button:active,
|
||||
:root[data-theme="retro"] button:active,
|
||||
:root[data-theme="retro"] .button.is-active {
|
||||
background: #c0c0c0;
|
||||
box-shadow: inset 1px 1px 0 #404040, inset -1px -1px 0 #ffffff, inset 2px 2px 0 #808080, inset -2px -2px 0 #dfdfdf;
|
||||
padding-top: calc(0.45rem + 1px);
|
||||
padding-left: calc(0.85rem + 1px);
|
||||
}
|
||||
|
||||
/* The primary call-to-action gets the blue title-bar gradient. */
|
||||
:root[data-theme="retro"] .button-primary {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-primary:hover {
|
||||
background: linear-gradient(to right, #0a0a9a, 80%, #1a90dd);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-danger {
|
||||
background: #c0c0c0;
|
||||
color: #c00000;
|
||||
border-color: #000000;
|
||||
}
|
||||
|
||||
/* Inputs and selects look sunken (inset bevel). */
|
||||
:root[data-theme="retro"] input,
|
||||
:root[data-theme="retro"] select,
|
||||
:root[data-theme="retro"] textarea {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] input:focus,
|
||||
:root[data-theme="retro"] select:focus {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
/* Labels inside windows read black, not muted-grey-on-grey. */
|
||||
:root[data-theme="retro"] label span,
|
||||
:root[data-theme="retro"] .stack-form label,
|
||||
:root[data-theme="retro"] .form-footer p,
|
||||
:root[data-theme="retro"] .drop-copy,
|
||||
:root[data-theme="retro"] .drop-meta,
|
||||
:root[data-theme="retro"] .upload-subtitle,
|
||||
:root[data-theme="retro"] .download-subtitle,
|
||||
:root[data-theme="retro"] .muted-copy,
|
||||
:root[data-theme="retro"] .kicker,
|
||||
:root[data-theme="retro"] .checkbox-field span {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* API / docs page: the header is a real full-width window with the intro text
|
||||
inside it, and each section card gets a Win98 title bar from its <h2>. */
|
||||
:root[data-theme="retro"] .docs-header {
|
||||
max-width: none;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||
background-color: #c0c0c0;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .docs-header .kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .docs-header p,
|
||||
:root[data-theme="retro"] .docs-header code {
|
||||
color: #000000;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .docs-card h2,
|
||||
:root[data-theme="retro"] .upload-options .options-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin: -1.5rem -1.5rem 1rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Make title bars flush to the window edge (a real Win98 title bar) wherever
|
||||
the heading is the top of its window: the upload card, the API header, and
|
||||
the API section cards. Pages where a heading sits below an icon or kicker
|
||||
(download/preview/login) keep the inset heading from the base h1 rule. */
|
||||
:root[data-theme="retro"] .card-content > h1:first-child,
|
||||
:root[data-theme="retro"] .docs-header h1 {
|
||||
margin: -1.5rem -1.5rem 1rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .docs-card h2::after,
|
||||
:root[data-theme="retro"] .upload-options .options-title::after {
|
||||
content: "✕";
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.15rem;
|
||||
height: 1rem;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Drop zone: a sunken white field with a dashed navy border. */
|
||||
:root[data-theme="retro"] .drop-zone {
|
||||
background: #ffffff;
|
||||
border: 2px dashed #000078;
|
||||
box-shadow: inset 2px 2px 0 #808080, inset -2px -2px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .drop-zone:hover,
|
||||
:root[data-theme="retro"] .drop-zone.is-dragging {
|
||||
background: #fffff0;
|
||||
border-color: #0f80cd;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .drop-icon {
|
||||
color: #000078;
|
||||
}
|
||||
|
||||
/* The hero "Welcome" badge becomes a high-contrast blinking pixel sticker
|
||||
that sits on the black desktop above the window. */
|
||||
:root[data-theme="retro"] .hero-eyebrow {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
border: 1px solid #000000;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #6f6fff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .hero-eyebrow::before {
|
||||
content: "★ ";
|
||||
color: #ffff66;
|
||||
animation: retro-blink 1.1s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .hero-eyebrow::after {
|
||||
content: " ★";
|
||||
color: #ffff66;
|
||||
animation: retro-blink 1.1s steps(1, end) 0.55s infinite;
|
||||
}
|
||||
|
||||
@keyframes retro-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root[data-theme="retro"] .hero-eyebrow::before,
|
||||
:root[data-theme="retro"] .hero-eyebrow::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badges become square chips. */
|
||||
:root[data-theme="retro"] .badge {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080;
|
||||
}
|
||||
|
||||
/* File / result rows: flat white with a sunken hairline. */
|
||||
:root[data-theme="retro"] .download-item,
|
||||
:root[data-theme="retro"] .result-item {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #dfdfdf, inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .file-main,
|
||||
:root[data-theme="retro"] .download-item small,
|
||||
:root[data-theme="retro"] .result-item code {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .file-emblem {
|
||||
background: #000078;
|
||||
color: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #4a4aff;
|
||||
}
|
||||
|
||||
/* Code blocks use the pixel mono font. */
|
||||
:root[data-theme="retro"] code,
|
||||
:root[data-theme="retro"] pre,
|
||||
:root[data-theme="retro"] pre code {
|
||||
font-family: "PixelOperatorMono", ui-monospace, monospace;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] pre {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080;
|
||||
}
|
||||
|
||||
/* Progress bar: blocky segmented Win98 look. */
|
||||
:root[data-theme="retro"] .progress {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .progress span {
|
||||
background: repeating-linear-gradient(90deg, #000078 0 8px, transparent 8px 10px), #0f80cd;
|
||||
}
|
||||
|
||||
/* Chunky retro scrollbars (WebKit/Blink). */
|
||||
:root[data-theme="retro"] ::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] ::-webkit-scrollbar-track {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] ::-webkit-scrollbar-thumb {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
/* Footer sits on the black desktop: white pixel text + a wink to the old web. */
|
||||
:root[data-theme="retro"] .site-footer {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .site-footer a,
|
||||
:root[data-theme="retro"] .footer-links a:not(.button) {
|
||||
color: #66ccff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .theme-picker > span {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .site-footer::after {
|
||||
content: "✩ Best viewed in 1024×768 ✩";
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:root[data-theme="retro"] .site-footer::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
363
backend/static/css/20-upload.css
Normal file
363
backend/static/css/20-upload.css
Normal file
@@ -0,0 +1,363 @@
|
||||
.upload-view {
|
||||
width: min(64rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Two-column upload layout: drop-zone window on the left, options on the
|
||||
right. Collapses to a single column on narrow screens (see 90-responsive). */
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.upload-main,
|
||||
.upload-options {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.options-title {
|
||||
margin: 0 0 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Stack the option fields vertically in the narrower right-hand window. */
|
||||
.upload-options .option-grid {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Summary + upload button sit at the bottom of the options window. */
|
||||
.upload-options .form-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.upload-options .form-footer .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
margin: 0 0 2.5rem 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.85rem;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
|
||||
.upload-subtitle {
|
||||
margin: 0.35rem 0 1.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 19rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 2rem;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-1);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.is-dragging {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-1-hover);
|
||||
}
|
||||
|
||||
.drop-zone input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.drop-icon svg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.drop-copy,
|
||||
.drop-meta {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drop-meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.advanced-options {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-options summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.form-footer,
|
||||
.result-header {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-footer p,
|
||||
#result-meta {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.55);
|
||||
}
|
||||
|
||||
.button-wide {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 0.4rem;
|
||||
margin-top: 0.55rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.progress span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
border-color: rgba(244, 244, 245, 0.24);
|
||||
background: rgba(244, 244, 245, 0.06);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.result-title svg {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.manage-link {
|
||||
margin: 0.9rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manage-link a {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-list,
|
||||
.download-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.upload-queue {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-item,
|
||||
.download-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item > span,
|
||||
.download-item > span {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-item strong,
|
||||
.download-item strong,
|
||||
.result-item code,
|
||||
.download-item code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: min(10rem, 32vw);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.file-progress-percent {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
height: 0.35rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result-item small,
|
||||
.download-item small,
|
||||
.result-item code,
|
||||
.download-item code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
274
backend/static/css/30-download.css
Normal file
274
backend/static/css/30-download.css
Normal file
@@ -0,0 +1,274 @@
|
||||
.download-view {
|
||||
width: min(38rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.download-view-wide {
|
||||
width: min(58rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.download-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-emblem {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.file-emblem svg {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.download-item {
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.button.is-active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.file-browser {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
flex: 0 0 4.75rem;
|
||||
width: 4.75rem;
|
||||
aspect-ratio: 16 / 10;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.thumb-link img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-main {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-action [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs {
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .thumb-link {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.images-only .file-card:not([data-kind="image"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
width: 10.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: color-mix(in srgb, var(--card) 96%, #000);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46);
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
width: 100%;
|
||||
min-height: 2.05rem;
|
||||
justify-content: flex-start;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
padding: 0.42rem 0.5rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.context-menu button:hover,
|
||||
.context-menu button:focus-visible,
|
||||
.context-menu button.is-copied {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.context-menu-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0.1rem 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
.context-menu-top small {
|
||||
color: color-mix(in srgb, var(--muted-foreground) 74%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-menu-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.context-menu-icons button {
|
||||
width: 1.9rem;
|
||||
min-height: 1.9rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.context-menu hr {
|
||||
height: 1px;
|
||||
margin: 0.35rem 0.2rem;
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unlock-form {
|
||||
margin: 1rem auto 0;
|
||||
display: grid;
|
||||
max-width: 22rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.manage-details {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manage-details div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.manage-details dt,
|
||||
.manage-details dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manage-details dt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.manage-details dd {
|
||||
color: var(--foreground);
|
||||
font-size: 0.84rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.preview-stage img,
|
||||
.preview-stage video {
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-stage audio {
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
}
|
||||
104
backend/static/css/40-docs.css
Normal file
104
backend/static/css/40-docs.css
Normal file
@@ -0,0 +1,104 @@
|
||||
.admin-view {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.docs-view {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
max-width: 44rem;
|
||||
}
|
||||
|
||||
.docs-header p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.docs-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.docs-card p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.docs-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.endpoint-list,
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.endpoint-list div,
|
||||
.field-grid {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.endpoint-list dt,
|
||||
.endpoint-list dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-list dt,
|
||||
.field-grid span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.endpoint-list dd code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docs-steps {
|
||||
margin: 0.85rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docs-steps li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.field-grid p {
|
||||
margin: 0;
|
||||
}
|
||||
190
backend/static/css/50-admin.css
Normal file
190
backend/static/css/50-admin.css
Normal file
@@ -0,0 +1,190 @@
|
||||
.admin-header,
|
||||
.table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(24, 24, 27, 0.78);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.table-header p {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--foreground);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-edit-metrics {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-table-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.table-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.table-header p {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Inline row edit (details/summary in table cells) */
|
||||
.row-edit {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.row-edit > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.row-edit > summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.row-edit[open] > summary {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.row-edit-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.row-edit-form input,
|
||||
.row-edit-form select {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
min-height: 1.9rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
}
|
||||
|
||||
.storage-edit-form {
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: end;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.storage-edit-form label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.storage-edit-form label span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.storage-edit-form textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.storage-edit-form .checkbox-field,
|
||||
.storage-edit-form button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.storage-edit-form {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
466
backend/static/css/60-storage.css
Normal file
466
backend/static/css/60-storage.css
Normal file
@@ -0,0 +1,466 @@
|
||||
/* ── Storage card UI ─────────────────────────────────────────────────────── */
|
||||
|
||||
.storage-stack {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.storage-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.storage-card.is-local {
|
||||
border-left: 3px solid rgba(125, 211, 252, 0.45);
|
||||
}
|
||||
|
||||
.storage-card.is-editing {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12);
|
||||
}
|
||||
|
||||
.storage-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.storage-card-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-card-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.storage-card-icon svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.storage-card-name {
|
||||
display: block;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.storage-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.storage-card-usage {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.storage-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* View-mode summary */
|
||||
.storage-card-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 1.75rem;
|
||||
padding: 0.65rem 1.1rem 0.9rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.storage-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.storage-detail > span:first-child,
|
||||
.storage-detail > code:first-child {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.storage-detail > span:last-child,
|
||||
.storage-detail > code:last-child {
|
||||
font-size: 0.82rem;
|
||||
color: var(--foreground);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.storage-detail-test > span:last-child {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.storage-detail-test.is-ok > span:last-child { color: #86efac; }
|
||||
.storage-detail-test.is-err > span:last-child { color: #fca5a5; }
|
||||
|
||||
/* Edit-mode body */
|
||||
.storage-card:not(.is-editing) .storage-card-body { display: none; }
|
||||
.storage-card.is-editing .storage-card-summary { display: none; }
|
||||
|
||||
.storage-card-body {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.storage-card-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.storage-card-fields label {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.storage-card-fields label span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.storage-card-fields textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.storage-card-fields .checkbox-field {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.storage-card-edit-bar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.storage-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.storage-type-option {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0.3rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.storage-type-option:hover {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3));
|
||||
}
|
||||
|
||||
.storage-type-option svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.storage-type-option strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.storage-type-option span {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.storage-ops-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.storage-op-card {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
align-content: start;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
}
|
||||
|
||||
.storage-op-card strong {
|
||||
color: var(--foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.storage-op-card span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.storage-op-card .button {
|
||||
justify-self: start;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.storage-form-note {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.storage-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.storage-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.storage-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.storage-modal-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(30rem, 100%);
|
||||
max-height: min(42rem, 90vh);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow, 0 1rem 2.5rem rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
.storage-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.storage-speed-form,
|
||||
.storage-results-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.storage-results-page {
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.storage-tests-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.storage-speed-option {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 90%, var(--muted));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.storage-speed-option span {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.storage-speed-option small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.storage-custom-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.storage-custom-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.storage-custom-fields label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.storage-result-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 92%, transparent);
|
||||
}
|
||||
|
||||
.storage-result-row summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.storage-test-progress {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem 0.65rem;
|
||||
}
|
||||
|
||||
.storage-test-progress-bar {
|
||||
height: 0.45rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.storage-test-progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: color-mix(in srgb, var(--primary) 70%, #86efac);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.storage-test-progress small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.storage-result-status {
|
||||
justify-self: end;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.storage-result-status.is-done { color: #86efac; }
|
||||
.storage-result-status.is-failed { color: #fca5a5; }
|
||||
.storage-result-status.is-running { color: #fde68a; }
|
||||
|
||||
.storage-result-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.storage-result-detail span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.76rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-result-detail strong {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.storage-result-error {
|
||||
grid-column: 1 / -1;
|
||||
color: #fca5a5 !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.storage-ops-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-result-row summary,
|
||||
.storage-result-detail,
|
||||
.storage-custom-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-result-status {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
59
backend/static/css/70-tokens.css
Normal file
59
backend/static/css/70-tokens.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* ── Access tokens ───────────────────────────────────────────────────────── */
|
||||
|
||||
.token-create-form {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-create-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.token-reveal {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(134, 239, 172, 0.3);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(134, 239, 172, 0.08);
|
||||
}
|
||||
|
||||
.token-reveal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.token-reveal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-reveal-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy {
|
||||
margin: 0.6rem 0 0;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy code {
|
||||
word-break: break-all;
|
||||
}
|
||||
98
backend/static/css/90-responsive.css
Normal file
98
backend/static/css/90-responsive.css
Normal file
@@ -0,0 +1,98 @@
|
||||
@media (max-width: 720px) {
|
||||
.nav-links {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.upload-view,
|
||||
.download-view {
|
||||
min-height: auto;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.option-grid,
|
||||
.form-footer,
|
||||
.result-header,
|
||||
.site-footer {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.docs-grid,
|
||||
.field-grid,
|
||||
.app-shell,
|
||||
.settings-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.endpoint-list div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
.admin-header,
|
||||
.table-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.new-collection-body {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf
Normal file
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf
Normal file
Binary file not shown.
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono.ttf
Normal file
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono.ttf
Normal file
Binary file not shown.
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf
Normal file
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf
Normal file
Binary file not shown.
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans.ttf
Normal file
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans.ttf
Normal file
Binary file not shown.
62
backend/static/js/00-utils.js
Normal file
62
backend/static/js/00-utils.js
Normal file
@@ -0,0 +1,62 @@
|
||||
(function () {
|
||||
window.Warpbox = window.Warpbox || {};
|
||||
|
||||
window.Warpbox.openInNewTab = function openInNewTab(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
window.Warpbox.writeClipboard = async function writeClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
};
|
||||
|
||||
window.Warpbox.copyText = async function copyText(text, button, copiedLabel) {
|
||||
if (!text || !button) {
|
||||
return;
|
||||
}
|
||||
await window.Warpbox.writeClipboard(text);
|
||||
const previous = button.textContent;
|
||||
button.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
button.textContent = previous;
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
window.Warpbox.formatDate = function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
window.Warpbox.formatBytes = function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
};
|
||||
})();
|
||||
53
backend/static/js/05-theme.js
Normal file
53
backend/static/js/05-theme.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Theme init + toggle.
|
||||
*
|
||||
* Loaded in <head> WITHOUT defer so the first block runs before paint and sets
|
||||
* the theme attribute, avoiding a flash of the wrong theme. The choice lives in
|
||||
* localStorage (no cookie, no server round-trip) and applies site-wide.
|
||||
*
|
||||
* CSP note: this is an external /static file, so it is allowed under
|
||||
* script-src 'self'. We only toggle an attribute / class — never inject inline
|
||||
* <style> — which keeps style-src 'self' happy.
|
||||
*/
|
||||
(function () {
|
||||
var STORAGE_KEY = "warpbox-theme";
|
||||
var THEMES = ["revamp", "classic", "retro"];
|
||||
|
||||
function stored() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
if (THEMES.indexOf(theme) === -1) {
|
||||
theme = "revamp";
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
return theme;
|
||||
}
|
||||
|
||||
// Runs immediately (before paint).
|
||||
var current = apply(stored() || "revamp");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var select = document.querySelector("[data-theme-select]");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reflect the active theme in the dropdown.
|
||||
select.value = current;
|
||||
|
||||
select.addEventListener("change", function () {
|
||||
current = apply(select.value);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, current);
|
||||
} catch (e) {
|
||||
/* ignore persistence failures (private mode, etc.) */
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
191
backend/static/js/10-file-browser.js
Normal file
191
backend/static/js/10-file-browser.js
Normal file
@@ -0,0 +1,191 @@
|
||||
(function () {
|
||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||
const previewImages = document.querySelector("[data-preview-images]");
|
||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||
|
||||
let ctrlCopyMode = false;
|
||||
let contextFile = null;
|
||||
const contextMenuCloseDistance = 80;
|
||||
|
||||
if (fileBrowser) {
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.getAttribute("data-view-button");
|
||||
fileBrowser.classList.toggle("is-list", view === "list");
|
||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
if (previewImages) {
|
||||
previewImages.addEventListener("click", () => {
|
||||
fileBrowser.classList.toggle("images-only");
|
||||
previewImages.classList.toggle("is-active");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileBrowser && fileContextMenu) {
|
||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||
const card = event.target.closest("[data-file-context]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
contextFile = {
|
||||
previewURL: card.dataset.previewUrl,
|
||||
viewURL: card.dataset.viewUrl,
|
||||
downloadURL: card.dataset.downloadUrl,
|
||||
fileName: card.dataset.fileName,
|
||||
};
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
fileContextMenu.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-context-action]");
|
||||
if (!button || !contextFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
||||
if (shouldHide !== false) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!fileContextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", hideContextMenu);
|
||||
window.addEventListener("scroll", hideContextMenu, true);
|
||||
}
|
||||
|
||||
if (previewActions.length > 0) {
|
||||
previewActions.forEach((button) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
if (!event.ctrlKey && !ctrlCopyMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await copyPreviewLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => setPreviewCopyMode(false));
|
||||
}
|
||||
|
||||
async function copyPreviewLink(button) {
|
||||
await window.Warpbox.writeClipboard(button.href);
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setPreviewCopyMode(enabled) {
|
||||
ctrlCopyMode = enabled;
|
||||
previewActions.forEach((button) => {
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
||||
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
||||
if (label) {
|
||||
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}
|
||||
if (viewIcon) {
|
||||
viewIcon.hidden = enabled;
|
||||
}
|
||||
if (copyIcon) {
|
||||
copyIcon.hidden = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runContextAction(action, file) {
|
||||
if (action === "preview") {
|
||||
window.Warpbox.openInNewTab(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "view") {
|
||||
window.Warpbox.openInNewTab(file.viewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-preview") {
|
||||
await window.Warpbox.writeClipboard(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-download") {
|
||||
await window.Warpbox.writeClipboard(file.downloadURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "download") {
|
||||
window.Warpbox.openInNewTab(file.downloadURL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
fileContextMenu.hidden = false;
|
||||
fileContextMenu.style.left = "0px";
|
||||
fileContextMenu.style.top = "0px";
|
||||
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
||||
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
||||
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
if (!fileContextMenu || fileContextMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
fileContextMenu.hidden = true;
|
||||
contextFile = null;
|
||||
}
|
||||
|
||||
function isPointerNearContextMenu(x, y) {
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
return x >= rect.left - contextMenuCloseDistance &&
|
||||
x <= rect.right + contextMenuCloseDistance &&
|
||||
y >= rect.top - contextMenuCloseDistance &&
|
||||
y <= rect.bottom + contextMenuCloseDistance;
|
||||
}
|
||||
})();
|
||||
115
backend/static/js/20-storage-admin.js
Normal file
115
backend/static/js/20-storage-admin.js
Normal file
@@ -0,0 +1,115 @@
|
||||
(function () {
|
||||
document.querySelectorAll("[data-storage-speed-open]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const modal = document.querySelector("[data-storage-speed-modal]");
|
||||
if (modal) {
|
||||
modal.hidden = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-storage-modal-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const modal = button.closest(".storage-modal");
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll(".storage-modal").forEach((modal) => {
|
||||
modal.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-speed-form").forEach((form) => {
|
||||
const customFields = form.querySelector("[data-storage-custom-fields]");
|
||||
function syncCustomFields() {
|
||||
if (!customFields) {
|
||||
return;
|
||||
}
|
||||
const customSelected = form.querySelector('input[name="mode"]:checked')?.value === "custom";
|
||||
customFields.hidden = !customSelected;
|
||||
customFields.querySelectorAll("input").forEach((input) => {
|
||||
input.disabled = !customSelected;
|
||||
});
|
||||
}
|
||||
form.querySelectorAll('input[name="mode"]').forEach((input) => {
|
||||
input.addEventListener("change", syncCustomFields);
|
||||
});
|
||||
syncCustomFields();
|
||||
});
|
||||
|
||||
const testList = document.querySelector("[data-storage-tests-page]");
|
||||
if (!testList) {
|
||||
return;
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function renderTest(test) {
|
||||
const progress = Math.max(0, Math.min(100, Number(test.progress || 0)));
|
||||
const error = test.error
|
||||
? `<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>`;
|
||||
}
|
||||
|
||||
async function refreshTests() {
|
||||
const url = testList.getAttribute("data-storage-tests-url");
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const openIDs = new Set(Array.from(testList.querySelectorAll("details[open]")).map((row) => row.dataset.storageTestId));
|
||||
const tests = payload.tests || [];
|
||||
if (tests.length === 0) {
|
||||
return;
|
||||
}
|
||||
testList.innerHTML = tests.map(renderTest).join("");
|
||||
testList.querySelectorAll("details").forEach((row) => {
|
||||
if (openIDs.has(row.dataset.storageTestId)) {
|
||||
row.open = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
refreshTests().catch(() => {});
|
||||
}, 1200);
|
||||
})();
|
||||
14
backend/static/js/30-token-copy.js
Normal file
14
backend/static/js/30-token-copy.js
Normal file
@@ -0,0 +1,14 @@
|
||||
(function () {
|
||||
const tokenCopyBtn = document.querySelector("[data-token-copy]");
|
||||
if (!tokenCopyBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokenCopyBtn.addEventListener("click", () => {
|
||||
const valueEl = document.querySelector("[data-token-value]");
|
||||
if (!valueEl) {
|
||||
return;
|
||||
}
|
||||
window.Warpbox.copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied");
|
||||
});
|
||||
})();
|
||||
274
backend/static/js/40-upload.js
Normal file
274
backend/static/js/40-upload.js
Normal file
@@ -0,0 +1,274 @@
|
||||
(function () {
|
||||
const form = document.querySelector("#upload-form");
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const progress = document.querySelector("#upload-progress");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const totalProgressBar = document.querySelector("#total-progress-bar");
|
||||
const copyURL = document.querySelector("#copy-url");
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember the last-chosen expiry across uploads (per browser).
|
||||
const expirySelect = form.querySelector("[data-expiry-select]");
|
||||
if (expirySelect) {
|
||||
const EXPIRY_KEY = "warpbox-expiry";
|
||||
let saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem(EXPIRY_KEY);
|
||||
} catch (e) {
|
||||
saved = null;
|
||||
}
|
||||
if (saved && expirySelect.querySelector('option[value="' + saved + '"]')) {
|
||||
expirySelect.value = saved;
|
||||
}
|
||||
expirySelect.addEventListener("change", () => {
|
||||
try {
|
||||
localStorage.setItem(EXPIRY_KEY, expirySelect.value);
|
||||
} catch (e) {
|
||||
/* ignore persistence failures */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateSelectedState(event.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => updateSelectedState(fileInput.files));
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = new FormData(form);
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderQueue(selectedFiles, "queued");
|
||||
setLoading(true, submit);
|
||||
|
||||
try {
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
});
|
||||
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
selectedFiles = Array.from(files || []);
|
||||
const count = selectedFiles.length || 0;
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (title) {
|
||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
if (fileSummary) {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
uploadQueue.hidden = true;
|
||||
uploadQueue.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(isLoading, submit) {
|
||||
if (progress) {
|
||||
progress.hidden = !isLoading;
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = isLoading;
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
if (!result || !resultList || !resultMeta || !openBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestBoxURL = payload.boxUrl;
|
||||
result.hidden = false;
|
||||
openBox.href = payload.boxUrl;
|
||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
|
||||
if (manageLink) {
|
||||
const anchor = manageLink.querySelector("a");
|
||||
manageLink.hidden = !payload.manageUrl;
|
||||
if (anchor && payload.manageUrl) {
|
||||
anchor.href = payload.manageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
resultList.replaceChildren();
|
||||
payload.files.forEach((file) => {
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadWithProgress(url, formData, files) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", url);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
updateStatus("Uploading...");
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
updateStatus(`${percent}%`);
|
||||
setTotalProgress(percent);
|
||||
setFileProgress(files, percent);
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(request.responseText || "{}");
|
||||
} catch (error) {
|
||||
reject(new Error("Upload response could not be read"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(payload.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
setTotalProgress(100);
|
||||
setFileProgress(files, 100);
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: window.Warpbox.formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createFileRow(file) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item upload-file-row";
|
||||
row.dataset.fileName = file.name;
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.className = "file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const meta = document.createElement("code");
|
||||
meta.textContent = file.meta;
|
||||
body.append(name, meta);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "file-progress-side";
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "file-progress-percent";
|
||||
percent.textContent = `${file.progress}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "progress file-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||
bar.append(fill);
|
||||
side.append(percent, bar);
|
||||
|
||||
row.append(body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setTotalProgress(percent) {
|
||||
if (totalProgressBar) {
|
||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setFileProgress(files, totalPercent) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const count = files.length || 1;
|
||||
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
||||
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
||||
const percent = row.querySelector(".file-progress-percent");
|
||||
const fill = row.querySelector(".file-progress span");
|
||||
if (percent) {
|
||||
percent.textContent = `${progress}%`;
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.transform = `scaleX(${progress / 100})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,626 +0,0 @@
|
||||
(function () {
|
||||
const form = document.querySelector("#upload-form");
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const progress = document.querySelector("#upload-progress");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const totalProgressBar = document.querySelector("#total-progress-bar");
|
||||
const copyURL = document.querySelector("#copy-url");
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||
const previewImages = document.querySelector("[data-preview-images]");
|
||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
||||
let ctrlCopyMode = false;
|
||||
let contextFile = null;
|
||||
const contextMenuCloseDistance = 80;
|
||||
|
||||
if (fileBrowser) {
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.getAttribute("data-view-button");
|
||||
fileBrowser.classList.toggle("is-list", view === "list");
|
||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
if (previewImages) {
|
||||
previewImages.addEventListener("click", () => {
|
||||
fileBrowser.classList.toggle("images-only");
|
||||
previewImages.classList.toggle("is-active");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileBrowser && fileContextMenu) {
|
||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||
const card = event.target.closest("[data-file-context]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
contextFile = {
|
||||
previewURL: card.dataset.previewUrl,
|
||||
viewURL: card.dataset.viewUrl,
|
||||
downloadURL: card.dataset.downloadUrl,
|
||||
fileName: card.dataset.fileName,
|
||||
};
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
fileContextMenu.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-context-action]");
|
||||
if (!button || !contextFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
||||
if (shouldHide !== false) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!fileContextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", hideContextMenu);
|
||||
window.addEventListener("scroll", hideContextMenu, true);
|
||||
}
|
||||
|
||||
if (previewActions.length > 0) {
|
||||
previewActions.forEach((button) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
if (!event.ctrlKey && !ctrlCopyMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await copyPreviewLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
setPreviewCopyMode(false);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (storageProviderSelects.length > 0) {
|
||||
storageProviderSelects.forEach((select) => {
|
||||
select.addEventListener("change", () => syncStorageProvider(select));
|
||||
syncStorageProvider(select);
|
||||
});
|
||||
}
|
||||
|
||||
/* Storage card edit / cancel toggles */
|
||||
document.querySelectorAll(".storage-edit-trigger").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".storage-card");
|
||||
if (!card) return;
|
||||
card.classList.add("is-editing");
|
||||
const providerSelect = card.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-cancel-trigger").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".storage-card");
|
||||
if (!card) return;
|
||||
const form = card.querySelector("form");
|
||||
if (form) form.reset();
|
||||
card.classList.remove("is-editing");
|
||||
});
|
||||
});
|
||||
|
||||
/* Add storage: type picker */
|
||||
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",
|
||||
};
|
||||
|
||||
const providerIconSVGs = {
|
||||
s3: storageNewCard && storageNewCard.querySelector(".storage-new-icon") ? storageNewCard.querySelector(".storage-new-icon").innerHTML : "",
|
||||
contabo: "",
|
||||
sftp: "",
|
||||
smb: "",
|
||||
webdav: "",
|
||||
};
|
||||
|
||||
if (storageAddTrigger && storageTypePicker) {
|
||||
storageAddTrigger.addEventListener("click", () => {
|
||||
storageTypePicker.hidden = !storageTypePicker.hidden;
|
||||
if (storageNewCard && !storageTypePicker.hidden) {
|
||||
storageNewCard.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
storageTypePicker.querySelectorAll(".storage-type-option").forEach((opt) => {
|
||||
opt.addEventListener("click", () => {
|
||||
const provider = opt.dataset.provider;
|
||||
if (!storageNewCard) return;
|
||||
|
||||
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
providerSelect.value = provider;
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
|
||||
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
||||
if (typeBadge) typeBadge.textContent = providerLabels[provider] || provider;
|
||||
|
||||
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
||||
const optIcon = opt.querySelector("svg");
|
||||
if (iconEl && optIcon) {
|
||||
iconEl.innerHTML = optIcon.outerHTML;
|
||||
}
|
||||
|
||||
storageTypePicker.hidden = true;
|
||||
storageNewCard.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (storageNewCard) {
|
||||
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
storageNewCard.hidden = true;
|
||||
if (storageTypePicker) storageTypePicker.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateSelectedState(event.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
updateSelectedState(fileInput.files);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = new FormData(form);
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderQueue(selectedFiles, "queued");
|
||||
setLoading(true, submit);
|
||||
|
||||
try {
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
});
|
||||
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
selectedFiles = Array.from(files || []);
|
||||
const count = selectedFiles.length || 0;
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (title) {
|
||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
if (fileSummary) {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
uploadQueue.hidden = true;
|
||||
uploadQueue.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(isLoading, submit) {
|
||||
if (progress) {
|
||||
progress.hidden = !isLoading;
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = isLoading;
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
if (!result || !resultList || !resultMeta || !openBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestBoxURL = payload.boxUrl;
|
||||
result.hidden = false;
|
||||
openBox.href = payload.boxUrl;
|
||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
||||
if (manageLink) {
|
||||
const anchor = manageLink.querySelector("a");
|
||||
manageLink.hidden = !payload.manageUrl;
|
||||
if (anchor && payload.manageUrl) {
|
||||
anchor.href = payload.manageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
resultList.replaceChildren();
|
||||
payload.files.forEach((file) => {
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadWithProgress(url, formData, files) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", url);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
updateStatus("Uploading...");
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
updateStatus(`${percent}%`);
|
||||
setTotalProgress(percent);
|
||||
setFileProgress(files, percent);
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(request.responseText || "{}");
|
||||
} catch (error) {
|
||||
reject(new Error("Upload response could not be read"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(payload.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
setTotalProgress(100);
|
||||
setFileProgress(files, 100);
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createFileRow(file) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item upload-file-row";
|
||||
row.dataset.fileName = file.name;
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.className = "file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const meta = document.createElement("code");
|
||||
meta.textContent = file.meta;
|
||||
body.append(name, meta);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "file-progress-side";
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "file-progress-percent";
|
||||
percent.textContent = `${file.progress}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "progress file-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||
bar.append(fill);
|
||||
side.append(percent, bar);
|
||||
|
||||
row.append(body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setTotalProgress(percent) {
|
||||
if (totalProgressBar) {
|
||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setFileProgress(files, totalPercent) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const count = files.length || 1;
|
||||
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
||||
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
||||
const percent = row.querySelector(".file-progress-percent");
|
||||
const fill = row.querySelector(".file-progress span");
|
||||
if (percent) {
|
||||
percent.textContent = `${progress}%`;
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.transform = `scaleX(${progress / 100})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(text, button, copiedLabel) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
await writeClipboard(text);
|
||||
const previous = button.textContent;
|
||||
button.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
button.textContent = previous;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
async function copyPreviewLink(button) {
|
||||
await writeClipboard(button.href);
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setPreviewCopyMode(enabled) {
|
||||
ctrlCopyMode = enabled;
|
||||
previewActions.forEach((button) => {
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
||||
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
||||
if (label) {
|
||||
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}
|
||||
if (viewIcon) {
|
||||
viewIcon.hidden = enabled;
|
||||
}
|
||||
if (copyIcon) {
|
||||
copyIcon.hidden = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runContextAction(action, file) {
|
||||
if (action === "preview") {
|
||||
openInNewTab(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "view") {
|
||||
openInNewTab(file.viewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-preview") {
|
||||
await writeClipboard(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-download") {
|
||||
await writeClipboard(file.downloadURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "download") {
|
||||
openInNewTab(file.downloadURL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
fileContextMenu.hidden = false;
|
||||
fileContextMenu.style.left = "0px";
|
||||
fileContextMenu.style.top = "0px";
|
||||
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
||||
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
||||
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
if (!fileContextMenu || fileContextMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
fileContextMenu.hidden = true;
|
||||
contextFile = null;
|
||||
}
|
||||
|
||||
function isPointerNearContextMenu(x, y) {
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
return x >= rect.left - contextMenuCloseDistance &&
|
||||
x <= rect.right + contextMenuCloseDistance &&
|
||||
y >= rect.top - contextMenuCloseDistance &&
|
||||
y <= rect.bottom + contextMenuCloseDistance;
|
||||
}
|
||||
|
||||
function openInNewTab(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function writeClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
})();
|
||||
@@ -15,8 +15,23 @@
|
||||
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<script defer src="/static/js/app.js"></script>
|
||||
<script src="/static/js/05-theme.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/00-base.css">
|
||||
<link rel="stylesheet" href="/static/css/10-layout.css">
|
||||
<link rel="stylesheet" href="/static/css/15-revamp.css">
|
||||
<link rel="stylesheet" href="/static/css/16-retro.css">
|
||||
<link rel="stylesheet" href="/static/css/20-upload.css">
|
||||
<link rel="stylesheet" href="/static/css/30-download.css">
|
||||
<link rel="stylesheet" href="/static/css/40-docs.css">
|
||||
<link rel="stylesheet" href="/static/css/50-admin.css">
|
||||
<link rel="stylesheet" href="/static/css/60-storage.css">
|
||||
<link rel="stylesheet" href="/static/css/70-tokens.css">
|
||||
<link rel="stylesheet" href="/static/css/90-responsive.css">
|
||||
<script defer src="/static/js/00-utils.js"></script>
|
||||
<script defer src="/static/js/10-file-browser.js"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js"></script>
|
||||
<script defer src="/static/js/30-token-copy.js"></script>
|
||||
<script defer src="/static/js/40-upload.js"></script>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
@@ -45,7 +60,15 @@
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
|
||||
<span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}} · self-hosted</span>
|
||||
<label class="theme-picker">
|
||||
<span>Theme</span>
|
||||
<select data-theme-select aria-label="Site theme">
|
||||
<option value="revamp">Aurora (default)</option>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="retro">Web 1.0 (retro)</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -42,6 +42,61 @@
|
||||
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-panel">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Access tokens</h2>
|
||||
<p>Personal tokens act as your account for the API and CLI. They never expire until you delete them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
|
||||
{{if .Data.NewToken}}
|
||||
<div class="token-reveal">
|
||||
<p class="token-reveal-title">Copy your new token now — it won't be shown again.</p>
|
||||
<div class="token-reveal-row">
|
||||
<code class="token-reveal-value" data-token-value>{{.Data.NewToken}}</code>
|
||||
<button class="button button-outline button-sm" type="button" data-token-copy>Copy</button>
|
||||
</div>
|
||||
<p class="muted-copy">Use it as a bearer token: <code>Authorization: Bearer {{.Data.NewToken}}</code></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="token-create-form" action="/account/tokens" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>Token name</span><input name="name" placeholder="e.g. CLI on laptop" maxlength="80" required></label>
|
||||
<button class="button button-primary button-sm" type="submit">Generate token</button>
|
||||
</form>
|
||||
|
||||
{{if .Data.Tokens}}
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Tokens}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.LastUsedAt}}</td>
|
||||
<td class="table-actions">
|
||||
<form action="/account/tokens/{{.ID}}/delete" method="post" onsubmit="return confirm('Delete this token? Any client using it will stop working.');">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted-copy">No tokens yet. Generate one above to use the API or CLI.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Upload policy</h2>
|
||||
<p>Admin users bypass all upload caps. Values are in megabytes.</p>
|
||||
<p>Admin users bypass all upload caps. Values are in megabytes; use -1 for unlimited upload size or daily upload caps.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,15 +28,36 @@
|
||||
<h1 id="admin-storage-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Local storage is always active. Remote backends are proxied through Warpbox.</p>
|
||||
</div>
|
||||
<a class="button button-primary" href="/admin/storage/new">{{template "icon-plus-circle" .}}<span>Add storage</span></a>
|
||||
</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-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}}
|
||||
<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-identity">
|
||||
<div class="storage-card-icon">
|
||||
@@ -59,12 +80,16 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-outline button-sm" type="submit">Test</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{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}}
|
||||
<form action="/admin/storage/{{.Config.ID}}/disable" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
@@ -79,7 +104,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* View-mode summary */}}
|
||||
<div class="storage-card-summary">
|
||||
{{if eq .Config.Type "local"}}
|
||||
<div class="storage-detail"><span>Path</span><code>{{.Config.LocalPath}}</code></div>
|
||||
@@ -107,141 +131,8 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</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>
|
||||
{{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>
|
||||
</section>
|
||||
|
||||
117
backend/templates/pages/admin_storage_form.html
Normal file
117
backend/templates/pages/admin_storage_form.html
Normal 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}}
|
||||
50
backend/templates/pages/admin_storage_new.html
Normal file
50
backend/templates/pages/admin_storage_new.html
Normal 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}}
|
||||
144
backend/templates/pages/admin_storage_tests.html
Normal file
144
backend/templates/pages/admin_storage_tests.html
Normal 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}}
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-grid user-edit-metrics">
|
||||
<article class="metric-card"><span>Storage used</span><strong>{{.Data.UserEdit.StorageUsed}}</strong></article>
|
||||
<article class="metric-card"><span>Uploaded today</span><strong>{{.Data.UserEdit.DailyUsed}}</strong></article>
|
||||
<article class="metric-card"><span>Effective quota</span><strong>{{.Data.UserEdit.EffectiveStorage}}</strong></article>
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Identity and limits</h2>
|
||||
<p>Blank limit fields inherit the global user defaults. Storage quota set to 0 means unlimited.</p>
|
||||
<p>Blank limit fields inherit the global user defaults. Use -1 for unlimited upload size or daily upload caps. Storage quota set to 0 means unlimited.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post">
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<span><code>file</code></span><p>One or more files for curl, browser, and generic multipart clients.</p>
|
||||
<span><code>sharex</code></span><p>One or more files from ShareX custom uploader configs.</p>
|
||||
<span><code>max_days</code></span><p>Optional number of days before expiration. Defaults to 7.</p>
|
||||
<span><code>expires_minutes</code></span><p>Optional lifetime in minutes. Takes precedence over <code>max_days</code> when greater than zero — useful for sub-day expiries (e.g. <code>60</code> for one hour).</p>
|
||||
<span><code>max_downloads</code></span><p>Optional download count limit.</p>
|
||||
<span><code>password</code></span><p>Optional password required before viewing/downloading.</p>
|
||||
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>; hides names/counts until unlock when a password is set.</p>
|
||||
|
||||
@@ -4,31 +4,46 @@
|
||||
<section class="upload-view" aria-labelledby="upload-title">
|
||||
<div class="hero-copy">
|
||||
{{if .CurrentUser}}
|
||||
<h1 id="upload-title">Upload files.</h1>
|
||||
<p>{{.Data.LimitSummary}}</p>
|
||||
<p class="hero-eyebrow">Welcome back, {{.CurrentUser.Username}}</p>
|
||||
{{else}}
|
||||
<h1 id="upload-title">Send a file. Get a link.</h1>
|
||||
<p>Anonymous, self-hosted transfers. No account required.</p>
|
||||
<p class="hero-eyebrow">Welcome</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form class="upload-panel card" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data">
|
||||
<div class="card-content">
|
||||
<label class="drop-zone" for="file-input">
|
||||
<span class="drop-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg>
|
||||
</span>
|
||||
<span class="drop-title">Drop files to upload</span>
|
||||
<span class="drop-copy">or click to browse</span>
|
||||
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
|
||||
<input id="file-input" name="file" type="file" multiple>
|
||||
</label>
|
||||
<form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data">
|
||||
<div class="card upload-main">
|
||||
<div class="card-content">
|
||||
{{if .CurrentUser}}
|
||||
<h1 id="upload-title">Drop it. Share it.</h1>
|
||||
<p class="upload-subtitle">{{.Data.LimitSummary}}</p>
|
||||
{{else}}
|
||||
<h1 id="upload-title">Send a file. Get a link.</h1>
|
||||
<p class="upload-subtitle">Fast, private transfers that expire on your terms.</p>
|
||||
{{end}}
|
||||
<label class="drop-zone" for="file-input">
|
||||
<span class="drop-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg>
|
||||
</span>
|
||||
<span class="drop-title">Drop files to upload</span>
|
||||
<span class="drop-copy">or click to browse</span>
|
||||
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
|
||||
<input id="file-input" name="file" type="file" multiple>
|
||||
</label>
|
||||
|
||||
<details class="advanced-options">
|
||||
<summary>
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
||||
Advanced options
|
||||
</summary>
|
||||
<div class="upload-progress" id="upload-progress" hidden>
|
||||
<div class="progress-row">
|
||||
<span>Uploading</span>
|
||||
<span id="upload-status">Preparing...</span>
|
||||
</div>
|
||||
<div class="progress"><span id="total-progress-bar"></span></div>
|
||||
</div>
|
||||
<div class="result-list upload-queue" id="upload-queue" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card upload-options">
|
||||
<div class="card-content">
|
||||
<h2 class="options-title">Upload options</h2>
|
||||
<div class="option-grid">
|
||||
{{if .CurrentUser}}
|
||||
<label>
|
||||
@@ -41,10 +56,8 @@
|
||||
{{end}}
|
||||
<label>
|
||||
<span>Expires in</span>
|
||||
<select name="max_days">
|
||||
<option value="7">7 days</option>
|
||||
<option value="1">1 day</option>
|
||||
<option value="30">30 days</option>
|
||||
<select name="expires_minutes" data-expiry-select>
|
||||
{{range .Data.ExpiryOptions}}<option value="{{.Minutes}}"{{if eq .Minutes $.Data.DefaultExpiryMinutes}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -60,20 +73,11 @@
|
||||
<span>Hide file names/count until unlocked</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="upload-progress" id="upload-progress" hidden>
|
||||
<div class="progress-row">
|
||||
<span>Uploading</span>
|
||||
<span id="upload-status">Preparing...</span>
|
||||
<div class="form-footer">
|
||||
<p id="file-summary">Choose one or more files to begin.</p>
|
||||
<button class="button button-primary" type="submit">Upload files</button>
|
||||
</div>
|
||||
<div class="progress"><span id="total-progress-bar"></span></div>
|
||||
</div>
|
||||
<div class="result-list upload-queue" id="upload-queue" hidden></div>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="file-summary">Choose one or more files to begin.</p>
|
||||
<button class="button button-primary" type="submit">Upload files</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -14,6 +14,11 @@ set -a
|
||||
source "${ENV_FILE}"
|
||||
set +a
|
||||
|
||||
if [[ -z "${APP_VERSION:-}" ]]; then
|
||||
APP_VERSION="$(git -C "${ROOT_DIR}" describe --tags --abbrev=0 2>/dev/null || printf 'dev')"
|
||||
export APP_VERSION
|
||||
fi
|
||||
|
||||
if [[ "${WARPBOX_DATA_DIR:-}" != /* ]]; then
|
||||
export WARPBOX_DATA_DIR="${ROOT_DIR}/${WARPBOX_DATA_DIR:-data}"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user