10 Commits

Author SHA1 Message Date
42449b3322 feat: add application versioning support to backend and UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
- Introduce APP_VERSION build argument and environment variable in Dockerfile.
- Load AppVersion from environment variables in the configuration loader.
- Pass the application version to the HTML renderer and expose it to templates via PageData.
- Update tests to verify the version is correctly rendered in the footer.
2026-05-31 20:21:37 +03:00
1513030c2a feat(admin): implement provider-specific storage configuration pages
Some checks failed
Build and Publish Docker Image / deploy (push) Has been cancelled
Refactor the admin storage backend creation and editing flows to use
provider-specific pages (e.g., `/admin/storage/new/sftp`) instead of a
single generic form. This ensures only relevant fields are rendered for
each storage provider (such as SFTP, S3, or WebDAV).

Additionally:
- Prevent mutation of the storage provider type during backend edits.
- Add comprehensive unit tests for provider-specific rendering, edit
  validation, and CSRF/admin route protection.
2026-05-31 19:52:46 +03:00
ac9b8232f3 feat(download): add dynamic OG metadata and fix thumbnail caching
- Register a new route for box Open Graph images (`/d/{boxID}/og-image.jpg`).
- Dynamically set the download page title, description, and OG image URL based on box state (e.g., file count, expiration, password protection).
- Introduce `servePlaceholderThumbnail` to serve fallback thumbnails with `Cache-Control: no-store, must-revalidate`. This ensures the browser requests the real thumbnail once it is generated instead of caching the placeholder.
2026-05-31 17:57:56 +03:00
704efb019c feat(ui): redesign upload page into a two-column layout
Redesigns the upload interface to use a two-column grid layout on larger screens, separating the file drop-zone (left) from the upload options (right). This improves usability and visual hierarchy.

Changes include:
- Increasing the upload view max-width to 64rem.
- Creating a responsive `.upload-grid` that collapses to a single column on narrow viewports.
- Stacking option fields vertically in the narrower options panel.
- Adding retro theme support for the new options title.
2026-05-31 16:41:04 +03:00
48d3c0475f style(retro): style docs header and cards as Win98 windows
Enhance the retro theme's API and documentation pages to better mimic
the Windows 98 aesthetic:

- Convert the docs header into a full-width grey window with black text.
- Style section card headings (`h2`) as classic blue gradient title bars, complete with a mock close button ("✕").
- Adjust margins to make top-level headings flush with window edges.
- Hide the kicker element in the docs header.
2026-05-31 16:23:51 +03:00
ffe4201f05 feat(theme): introduce retro Windows 98-inspired theme
Add a new "retro" theme option that mimics the classic Windows 98 aesthetic, providing a nostalgic alternative to the modern and classic dark themes.

Changes include:
- Defining CSS variables for the retro theme in `00-base.css` (pixel fonts, silver/gray colors, and classic window shadows).
- Adding custom styling for cards, headers, buttons, and title bars to replicate classic OS windows.
- Adding a star background GIF (`stars1.gif`).
- Excluding the retro theme from modern "revamp" styles in `15-revamp.css`.
- Updating `CLAUDE.md` with instructions on screenshot verification.
2026-05-31 16:17:20 +03:00
df91fe9d3d feat(upload): add dynamic expiry options and modern UI theme
- Implement dynamic expiry options on the upload page based on user roles and retention policies.
- Add helper functions to build and format expiry options into human-readable labels.
- Introduce a new modern theme featuring glassmorphism, gradients, and frosted glass cards.
2026-05-31 15:30:53 +03:00
f1c67c455b feat(config): allow -1 to represent unlimited upload limits
Introduce support for configuring unlimited upload limits by allowing -1
as a valid value for anonymous and user upload MB limits.

Changes include:
- Added `envMegabytesLimitFloat` and helper functions to parse and validate limits where -1 is allowed.
- Updated validation logic to accept -1 for `AnonymousMaxUploadMB`, `AnonymousDailyUploadMB`, and `UserDailyUploadMB`.
- Added a test case to verify unlimited upload policy behavior.
2026-05-31 14:01:38 +03:00
61b7c283a4 fix(auth): reject invalid bearer tokens instead of falling back
Modify the authentication handler to return an unauthorized error when
an invalid or disabled bearer token is provided, rather than silently
falling back to an anonymous request.

This ensures that clients attempting to authenticate but failing (due to
expired, malformed, or disabled tokens) are explicitly notified of the
auth failure instead of proceeding anonymously. True anonymous requests
without any Authorization header remain supported.
2026-05-31 13:02:58 +03:00
d99f8ee82a feat(auth): support API tokens and bearer token authentication
- Add backend services to create, list, and delete API tokens.
- Implement Bearer token authentication to resolve tokens to users.
- Register HTTP routes for managing user tokens under `/account/tokens`.
- Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
2026-05-31 12:50:13 +03:00
66 changed files with 7335 additions and 3541 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,15 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"time"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
@@ -22,12 +26,56 @@ type adminPageData struct {
Settings services.UploadPolicySettings
Storage []services.StorageBackendView
UserEdit adminUserEditView
StorageForm adminStorageFormView
StorageTest adminStorageTestView
StorageTypes []adminStorageProviderView
Section string
PageTitle string
LastInviteURL string
Notice string
Error string
}
type adminStorageFormView struct {
Mode string
Provider string
ProviderLabel string
Action string
BackHref string
Config services.StorageBackendConfig
}
type adminStorageTestView struct {
Config services.StorageBackendConfig
UsageLabel string
Tests []services.StorageSpeedTest
CanRun bool
}
type adminStorageSpeedTestJSON struct {
ID string `json:"id"`
Mode string `json:"mode"`
ModeLabel string `json:"modeLabel"`
Status string `json:"status"`
Stage string `json:"stage"`
Progress int `json:"progress"`
CustomLabel string `json:"customLabel,omitempty"`
StartedLabel string `json:"startedLabel"`
FinishedLabel string `json:"finishedLabel"`
Files int `json:"files"`
SizeLabel string `json:"sizeLabel"`
WriteSpeed string `json:"writeSpeed"`
ReadSpeed string `json:"readSpeed"`
Error string `json:"error,omitempty"`
}
type adminStorageProviderView struct {
Provider string
Label string
Description string
Icon string
}
type adminBoxView struct {
ID string
Owner string
@@ -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

View File

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

View File

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

View File

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

View 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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,10 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
}
}
func RunCleanupNow(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
return cleanupUnavailableBoxes(uploadService, logger)
}
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
boxes, err := uploadService.ListBoxes(0)
if err != nil {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, "/"))), "./")
}

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

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

View 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://")
}

View 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))
}

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

View File

@@ -0,0 +1,424 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"os"
"path/filepath"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var storageSpeedTestsBucket = []byte("storage_speed_tests")
const (
StorageSpeedModeSmall = "small"
StorageSpeedModeBig = "big"
StorageSpeedModeMixed = "mixed"
StorageSpeedModeCustom = "custom"
StorageSpeedStatusRunning = "running"
StorageSpeedStatusDone = "done"
StorageSpeedStatusFailed = "failed"
)
type StorageSpeedTest struct {
ID string `json:"id"`
BackendID string `json:"backendId"`
BackendName string `json:"backendName"`
Mode string `json:"mode"`
Status string `json:"status"`
Stage string `json:"stage"`
ProgressPercent int `json:"progressPercent"`
CustomFileCount int `json:"customFileCount,omitempty"`
CustomFileSizeMB float64 `json:"customFileSizeMb,omitempty"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt,omitempty"`
BytesWritten int64 `json:"bytesWritten"`
BytesRead int64 `json:"bytesRead"`
FilesWritten int `json:"filesWritten"`
WriteDurationMS int64 `json:"writeDurationMs"`
ReadDurationMS int64 `json:"readDurationMs"`
DeleteDurationMS int64 `json:"deleteDurationMs"`
Error string `json:"error,omitempty"`
}
func (t StorageSpeedTest) ModeLabel() string {
switch t.Mode {
case StorageSpeedModeSmall:
return "Many small files"
case StorageSpeedModeBig:
return "One big file"
case StorageSpeedModeMixed:
return "Average mix"
case StorageSpeedModeCustom:
return "Custom"
default:
return t.Mode
}
}
func (t StorageSpeedTest) StartedLabel() string {
if t.StartedAt.IsZero() {
return ""
}
return t.StartedAt.Format("Jan 2, 15:04:05")
}
func (t StorageSpeedTest) FinishedLabel() string {
if t.FinishedAt.IsZero() {
return "Still running"
}
return t.FinishedAt.Format("Jan 2, 15:04:05")
}
func (t StorageSpeedTest) TotalSizeLabel() string {
return FormatMegabytesFromBytes(max(t.BytesWritten, t.BytesRead))
}
func (t StorageSpeedTest) WriteSpeedLabel() string {
return speedLabel(t.BytesWritten, t.WriteDurationMS)
}
func (t StorageSpeedTest) ReadSpeedLabel() string {
return speedLabel(t.BytesRead, t.ReadDurationMS)
}
func speedLabel(bytes int64, durationMS int64) string {
if bytes <= 0 || durationMS <= 0 {
return "n/a"
}
mb := float64(bytes) / 1024 / 1024
seconds := float64(durationMS) / 1000
value := math.Round((mb/seconds)*100) / 100
return fmt.Sprintf("%.2f MB/s", value)
}
func (s *StorageService) StartSpeedTest(backendID, mode string) (StorageSpeedTest, error) {
return s.StartSpeedTestWithOptions(backendID, StorageSpeedTestOptions{Mode: mode})
}
type StorageSpeedTestOptions struct {
Mode string
CustomFileCount int
CustomFileSizeMB float64
}
func (s *StorageService) StartSpeedTestWithOptions(backendID string, options StorageSpeedTestOptions) (StorageSpeedTest, error) {
cfg, err := s.BackendConfig(backendID)
if err != nil {
return StorageSpeedTest{}, err
}
if !cfg.Enabled {
return StorageSpeedTest{}, fmt.Errorf("storage backend is disabled")
}
if !cfg.LastTestSuccess {
return StorageSpeedTest{}, fmt.Errorf("run a successful connection test before testing speed")
}
mode := normalizeSpeedTestMode(options.Mode)
if mode == StorageSpeedModeCustom {
if err := validateCustomSpeedTest(options.CustomFileCount, options.CustomFileSizeMB); err != nil {
return StorageSpeedTest{}, err
}
}
test := StorageSpeedTest{
ID: randomID(10),
BackendID: cfg.ID,
BackendName: cfg.Name,
Mode: mode,
Status: StorageSpeedStatusRunning,
Stage: "queued",
CustomFileCount: options.CustomFileCount,
CustomFileSizeMB: options.CustomFileSizeMB,
StartedAt: time.Now().UTC(),
}
if err := s.saveSpeedTest(test); err != nil {
return StorageSpeedTest{}, err
}
return test, nil
}
func (s *StorageService) RunSpeedTest(ctx context.Context, testID string) {
test, err := s.speedTest(testID)
if err != nil {
return
}
if err := s.runSpeedTest(ctx, &test); err != nil {
test.Status = StorageSpeedStatusFailed
test.Error = err.Error()
test.FinishedAt = time.Now().UTC()
if test.Stage == "" || test.Stage == "queued" {
test.Stage = "failed"
}
_ = s.saveSpeedTest(test)
return
}
test.Status = StorageSpeedStatusDone
test.Stage = "complete"
test.ProgressPercent = 100
test.FinishedAt = time.Now().UTC()
_ = s.saveSpeedTest(test)
}
func (s *StorageService) ListSpeedTests(backendID string, limit int) ([]StorageSpeedTest, error) {
var tests []StorageSpeedTest
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(storageSpeedTestsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, value []byte) error {
var test StorageSpeedTest
if err := json.Unmarshal(value, &test); err != nil {
return err
}
if backendID == "" || test.BackendID == backendID {
tests = append(tests, test)
}
return nil
})
})
if err != nil {
return nil, err
}
sort.Slice(tests, func(i, j int) bool {
return tests[i].StartedAt.After(tests[j].StartedAt)
})
if limit > 0 && len(tests) > limit {
tests = tests[:limit]
}
return tests, nil
}
func (s *StorageService) speedTest(id string) (StorageSpeedTest, error) {
var test StorageSpeedTest
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(storageSpeedTestsBucket).Get([]byte(id))
if data == nil {
return fmt.Errorf("speed test not found")
}
return json.Unmarshal(data, &test)
})
return test, err
}
func (s *StorageService) saveSpeedTest(test StorageSpeedTest) error {
data, err := json.Marshal(test)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(storageSpeedTestsBucket).Put([]byte(test.ID), data)
})
}
func (s *StorageService) runSpeedTest(ctx context.Context, test *StorageSpeedTest) error {
backend, err := s.Backend(test.BackendID)
if err != nil {
return err
}
files, err := createSpeedTestFiles(test)
if err != nil {
return err
}
defer os.RemoveAll(files.Root)
keys := make([]string, 0, len(files.Files))
defer func() {
for _, key := range keys {
_ = backend.Delete(context.Background(), key)
}
}()
writeStart := time.Now()
for i, file := range files.Files {
key := fmt.Sprintf(".warpbox-speed-test/%s/%03d.bin", test.ID, i)
source, err := os.Open(file.Path)
if err != nil {
return err
}
err = backend.Put(ctx, key, source, file.Size, "application/octet-stream")
source.Close()
if err != nil {
return err
}
keys = append(keys, key)
test.BytesWritten += file.Size
test.FilesWritten++
updateSpeedProgress(test, "writing", i+1, len(files.Files), 0, 45)
_ = s.saveSpeedTest(*test)
}
test.WriteDurationMS = time.Since(writeStart).Milliseconds()
_ = s.saveSpeedTest(*test)
readStart := time.Now()
for i, key := range keys {
object, err := backend.Get(ctx, key)
if err != nil {
return err
}
read, err := io.Copy(io.Discard, object.Body)
object.Body.Close()
if err != nil {
return err
}
test.BytesRead += read
updateSpeedProgress(test, "reading", i+1, len(keys), 45, 90)
_ = s.saveSpeedTest(*test)
}
test.ReadDurationMS = time.Since(readStart).Milliseconds()
_ = s.saveSpeedTest(*test)
deleteStart := time.Now()
for i, key := range keys {
if err := backend.Delete(ctx, key); err != nil {
return err
}
updateSpeedProgress(test, "cleaning up", i+1, len(keys), 90, 100)
_ = s.saveSpeedTest(*test)
}
test.DeleteDurationMS = time.Since(deleteStart).Milliseconds()
keys = nil
return nil
}
func updateSpeedProgress(test *StorageSpeedTest, stage string, done, total, start, end int) {
test.Stage = stage
if total <= 0 {
test.ProgressPercent = start
return
}
span := end - start
progress := start + int(math.Round(float64(span)*float64(done)/float64(total)))
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
test.ProgressPercent = progress
}
type speedTestFile struct {
Path string
Size int64
}
type speedTestFiles struct {
Root string
Files []speedTestFile
}
func createSpeedTestFiles(test *StorageSpeedTest) (speedTestFiles, error) {
plan, err := speedTestPlan(test)
if err != nil {
return speedTestFiles{}, err
}
root, err := os.MkdirTemp("", "warpbox-speed-test-*")
if err != nil {
return speedTestFiles{}, err
}
files := speedTestFiles{Root: root, Files: make([]speedTestFile, 0, len(plan))}
for i, size := range plan {
path := filepath.Join(root, fmt.Sprintf("%03d.bin", i))
if err := writeMockFile(path, size, byte(65+(i%23))); err != nil {
os.RemoveAll(root)
return speedTestFiles{}, err
}
files.Files = append(files.Files, speedTestFile{Path: path, Size: size})
}
return files, nil
}
func speedTestPlan(test *StorageSpeedTest) ([]int64, error) {
mode := normalizeSpeedTestMode(test.Mode)
if mode == StorageSpeedModeCustom {
if err := validateCustomSpeedTest(test.CustomFileCount, test.CustomFileSizeMB); err != nil {
return nil, err
}
size := MegabytesToBytes(test.CustomFileSizeMB)
plan := make([]int64, test.CustomFileCount)
for i := range plan {
plan[i] = size
}
return plan, nil
}
return speedTestPlanForMode(mode), nil
}
func speedTestPlanForMode(mode string) []int64 {
mode = normalizeSpeedTestMode(mode)
switch mode {
case StorageSpeedModeSmall:
return repeatedSizes(24, 32*1024)
case StorageSpeedModeBig:
return repeatedSizes(1, 8*1024*1024)
default:
sizes := repeatedSizes(8, 64*1024)
return append(sizes, repeatedSizes(1, 4*1024*1024)...)
}
}
func repeatedSizes(count int, size int64) []int64 {
sizes := make([]int64, 0, count)
for i := 0; i < count; i++ {
sizes = append(sizes, size)
}
return sizes
}
func writeMockFile(path string, size int64, seed byte) error {
target, err := os.Create(path)
if err != nil {
return err
}
defer target.Close()
chunk := make([]byte, 64*1024)
for i := range chunk {
chunk[i] = seed
}
remaining := size
for remaining > 0 {
writeSize := int64(len(chunk))
if remaining < writeSize {
writeSize = remaining
}
if _, err := target.Write(chunk[:int(writeSize)]); err != nil {
return err
}
remaining -= writeSize
}
return nil
}
func validateCustomSpeedTest(count int, sizeMB float64) error {
if count <= 0 || count > 500 {
return fmt.Errorf("custom speed test file count must be between 1 and 500")
}
if sizeMB <= 0 {
return fmt.Errorf("custom speed test file size must be positive")
}
totalMB := float64(count) * sizeMB
if totalMB > 4096 {
return fmt.Errorf("custom speed test total size cannot exceed 4096 MB")
}
return nil
}
func normalizeSpeedTestMode(mode string) string {
switch strings.TrimSpace(mode) {
case StorageSpeedModeSmall:
return StorageSpeedModeSmall
case StorageSpeedModeBig:
return StorageSpeedModeBig
case StorageSpeedModeCustom:
return StorageSpeedModeCustom
default:
return StorageSpeedModeMixed
}
}

View File

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

View File

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

View File

@@ -172,6 +172,157 @@ func TestSFTPStorageConfigValidation(t *testing.T) {
}
}
func TestStorageUpdateRejectsProviderMutation(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "SFTP",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderS3,
Name: "Mutated",
Endpoint: "https://s3.example.test",
Bucket: "bucket",
AccessKey: "access",
SecretKey: "secret",
UseSSL: true,
}); err == nil {
t.Fatalf("UpdateBackend allowed provider mutation")
}
stored, err := service.Storage().BackendConfig(cfg.ID)
if err != nil {
t.Fatalf("BackendConfig returned error: %v", err)
}
if stored.Provider != StorageProviderSFTP || stored.Type != StorageBackendSFTP {
t.Fatalf("provider/type mutated despite error: %+v", stored)
}
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderSFTP,
Type: StorageBackendS3,
Name: "Mutated",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
}); err == nil {
t.Fatalf("UpdateBackend allowed type mutation")
}
}
func TestStorageUpdatePreservesSecretsWhenBlank(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "SFTP",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
PrivateKey: "private-key",
HostKey: "host-key",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "SFTP renamed",
Host: "files.example.test",
Username: "warpbox",
})
if err != nil {
t.Fatalf("UpdateBackend returned error: %v", err)
}
if updated.Password != "secret" || updated.PrivateKey != "private-key" || updated.HostKey != "host-key" {
t.Fatalf("blank secret fields were not preserved: %+v", updated)
}
}
func TestContaboUpdateKeepsTLSAndPathStyleLocked(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
Provider: StorageProviderContabo,
Name: "Contabo",
Endpoint: "https://eu2.contabostorage.com",
Bucket: "My Main Bucket",
AccessKey: "access",
SecretKey: "secret",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderContabo,
Name: "Contabo",
Endpoint: "https://eu2.contabostorage.com",
Bucket: "My Main Bucket",
AccessKey: "access",
SecretKey: "secret",
UseSSL: false,
PathStyle: false,
})
if err != nil {
t.Fatalf("UpdateBackend returned error: %v", err)
}
if !updated.UseSSL || !updated.PathStyle {
t.Fatalf("contabo TLS/path-style were not locked: %+v", updated)
}
}
func TestStorageSpeedTestRequiresConnectionAndRuns(t *testing.T) {
service := newTestUploadService(t)
if _, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall); err == nil {
t.Fatalf("StartSpeedTest allowed speed test before connection test")
}
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
t.Fatalf("TestBackend local returned error: %v", err)
}
test, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall)
if err != nil {
t.Fatalf("StartSpeedTest returned error: %v", err)
}
service.Storage().RunSpeedTest(testContext(), test.ID)
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
if err != nil {
t.Fatalf("ListSpeedTests returned error: %v", err)
}
if len(tests) != 1 {
t.Fatalf("speed tests len = %d, want 1", len(tests))
}
got := tests[0]
if got.Status != StorageSpeedStatusDone || got.ProgressPercent != 100 || got.Stage != "complete" || got.BytesWritten == 0 || got.BytesRead == 0 || got.FilesWritten == 0 {
t.Fatalf("speed test did not complete with metrics: %+v", got)
}
}
func TestCustomStorageSpeedTestUsesRequestedFiles(t *testing.T) {
service := newTestUploadService(t)
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
t.Fatalf("TestBackend local returned error: %v", err)
}
test, err := service.Storage().StartSpeedTestWithOptions(StorageBackendLocal, StorageSpeedTestOptions{
Mode: StorageSpeedModeCustom,
CustomFileCount: 3,
CustomFileSizeMB: 0.001,
})
if err != nil {
t.Fatalf("StartSpeedTestWithOptions returned error: %v", err)
}
service.Storage().RunSpeedTest(testContext(), test.ID)
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
if err != nil {
t.Fatalf("ListSpeedTests returned error: %v", err)
}
got := tests[0]
if got.Mode != StorageSpeedModeCustom || got.CustomFileCount != 3 || got.CustomFileSizeMB != 0.001 || got.FilesWritten != 3 || got.Status != StorageSpeedStatusDone {
t.Fatalf("custom speed test did not use requested files: %+v", got)
}
}
func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) {
service := newTestUploadService(t)
smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View 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;
}

View 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;
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View 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%;
}
}

View 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;
}
}

View 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;
}

View 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

Binary file not shown.

View 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]}`;
};
})();

View 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.) */
}
});
});
})();

View 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;
}
})();

View 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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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);
})();

View 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");
});
})();

View 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})`;
}
});
}
})();

View File

@@ -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]}`;
}
})();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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