Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d2ea0204 | |||
| ffa2d9636b | |||
| cc91ce120d | |||
| 73bd14572d | |||
| 4eacb4cde2 | |||
| 71d9b9db7e | |||
| 01996c0445 | |||
| adb1a12dfd | |||
| 10ed806153 | |||
| 2d04a42736 | |||
| 42449b3322 | |||
| 1513030c2a | |||
| ac9b8232f3 | |||
| 704efb019c | |||
| 48d3c0475f | |||
| ffe4201f05 | |||
| df91fe9d3d | |||
| f1c67c455b | |||
| 61b7c283a4 | |||
| d99f8ee82a |
@@ -30,3 +30,4 @@ WARPBOX_USER_STORAGE_BACKEND=local
|
||||
WARPBOX_READ_TIMEOUT=15s
|
||||
WARPBOX_WRITE_TIMEOUT=60s
|
||||
WARPBOX_IDLE_TIMEOUT=120s
|
||||
WARPBOX_TRUSTED_PROXIES=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,5 +12,8 @@ backend/static/uploads/*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.prod.env
|
||||
scripts/env/dev.env
|
||||
docker-compose.yml
|
||||
|
||||
.claude
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Important Nots
|
||||
Do not take screenshots yourself, ask the user to take screenshots of your visual changes if you want to so that you can verify them.
|
||||
|
||||
## Commands
|
||||
|
||||
**Go Executable:**
|
||||
|
||||
@@ -16,12 +16,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
RUN apk add --no-cache ca-certificates ffmpeg wget
|
||||
|
||||
ENV WARPBOX_ADDR=:8080 \
|
||||
WARPBOX_DATA_DIR=/data \
|
||||
WARPBOX_STATIC_DIR=/app/static \
|
||||
WARPBOX_TEMPLATE_DIR=/app/templates
|
||||
WARPBOX_TEMPLATE_DIR=/app/templates \
|
||||
APP_VERSION=${APP_VERSION}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
87
README.md
87
README.md
@@ -33,6 +33,7 @@ Upload policy defaults are also configured in megabytes and can later be changed
|
||||
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
|
||||
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
|
||||
- `WARPBOX_USER_STORAGE_BACKEND=local`
|
||||
- `WARPBOX_TRUSTED_PROXIES=` controls whether forwarded client IP headers are accepted only from specific proxy IPs/CIDRs. See [SECURITY_PROXY.md](./SECURITY_PROXY.md).
|
||||
|
||||
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
|
||||
The dev script resolves that path from the repository root.
|
||||
@@ -74,6 +75,73 @@ The compose example also works with Podman compatible compose tools. Its data vo
|
||||
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks
|
||||
use `/health`.
|
||||
|
||||
## Reverse Proxy Security
|
||||
|
||||
Warpbox uses the resolved client IP for anonymous limits, manual bans, and automatic bans. The
|
||||
default behavior trusts `X-Forwarded-For` and `X-Real-IP` so a normal Caddy reverse proxy works
|
||||
without extra setup. For hardened deployments where the app port might be reachable from more than
|
||||
one network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
|
||||
[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes.
|
||||
|
||||
## Systemd
|
||||
|
||||
Build the binary on the server, create a dedicated user, and keep runtime data outside the repo:
|
||||
|
||||
```bash
|
||||
cd /opt/warpbox-dev/backend
|
||||
go build -o /usr/local/bin/warpbox ./cmd/warpbox
|
||||
sudo useradd --system --home /var/lib/warpbox --shell /usr/sbin/nologin warpbox
|
||||
sudo mkdir -p /var/lib/warpbox /etc/warpbox
|
||||
sudo chown -R warpbox:warpbox /var/lib/warpbox
|
||||
sudo cp /opt/warpbox-dev/.env.example /etc/warpbox/warpbox.env
|
||||
```
|
||||
|
||||
Example `/etc/warpbox/warpbox.env` values:
|
||||
|
||||
```env
|
||||
WARPBOX_ENV=production
|
||||
WARPBOX_ADDR=127.0.0.1:6070
|
||||
WARPBOX_BASE_URL=https://warpbox.dev
|
||||
WARPBOX_DATA_DIR=/var/lib/warpbox
|
||||
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
|
||||
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
|
||||
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
|
||||
```
|
||||
|
||||
Example `/etc/systemd/system/warpbox.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Warpbox file sharing service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=warpbox
|
||||
Group=warpbox
|
||||
EnvironmentFile=/etc/warpbox/warpbox.env
|
||||
ExecStart=/usr/local/bin/warpbox
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/warpbox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then enable it:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now warpbox
|
||||
sudo systemctl status warpbox
|
||||
```
|
||||
|
||||
Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the public internet.
|
||||
|
||||
## Layout
|
||||
|
||||
- `backend/cmd/warpbox` - main application entry point.
|
||||
@@ -114,7 +182,7 @@ Curl and custom uploaders can use the same endpoint:
|
||||
# Terminal-friendly output: one plain box URL.
|
||||
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
||||
|
||||
# JSON output with boxUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
||||
# JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
||||
curl -F sharex=@./screenshot.png \
|
||||
-H 'Accept: application/json' \
|
||||
http://localhost:8080/api/v1/upload
|
||||
@@ -122,6 +190,19 @@ curl -F sharex=@./screenshot.png \
|
||||
|
||||
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
||||
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
||||
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header — mint
|
||||
a token under **Account → Access tokens**. The JSON response uses ShareX placeholders
|
||||
`{json:boxUrl}` (URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and
|
||||
`{json:error}` (error message).
|
||||
|
||||
### Grouping multiple files into one box (`X-Warpbox-Batch`)
|
||||
|
||||
By default every uploaded file becomes its own box. To put several files in a **single** box, send
|
||||
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
|
||||
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
|
||||
ShareX selection — which ShareX sends as separate back-to-back requests — land as one shareable
|
||||
link. The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file.
|
||||
Requests without the header behave exactly as before.
|
||||
|
||||
## Stage 4 Accounts + Personal Boxes
|
||||
|
||||
@@ -138,6 +219,8 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your
|
||||
user storage quota, and usage retention.
|
||||
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
||||
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
|
||||
- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and
|
||||
repeated login failures. Auto-ban is off by default and configured from the admin UI.
|
||||
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
|
||||
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
|
||||
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
|
||||
@@ -158,6 +241,8 @@ Warpbox keeps local runtime data under the configured data directory:
|
||||
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
|
||||
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
|
||||
for anonymous uploads and user ID for signed-in uploads.
|
||||
- `data/db/warpbox.bbolt` stores manual bans, automatic ban settings, abuse counters, and malicious
|
||||
path rules.
|
||||
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
||||
|
||||
## Static Asset Policy
|
||||
|
||||
69
SECURITY_PROXY.md
Normal file
69
SECURITY_PROXY.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Security Proxy Notes
|
||||
|
||||
Warpbox usually runs behind a reverse proxy such as Caddy. IP-based quotas,
|
||||
manual bans, and automatic bans depend on Warpbox seeing the real client IP.
|
||||
|
||||
## Caddy
|
||||
|
||||
Use this shape when Caddy and Warpbox are on the same host:
|
||||
|
||||
```Caddyfile
|
||||
warpbox.dev {
|
||||
reverse_proxy 127.0.0.1:6070 {
|
||||
header_up X-Forwarded-For {http.request.remote.host}
|
||||
header_up X-Real-IP {http.request.remote.host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By default, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` so simple Docker,
|
||||
Podman, and systemd deployments work without extra setup. This is convenient,
|
||||
but it is only safe when the Warpbox port is not directly reachable by the
|
||||
public internet.
|
||||
|
||||
## Trusted Proxies
|
||||
|
||||
For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR
|
||||
ranges that are allowed to provide forwarded headers. Use proxy IPs only.
|
||||
|
||||
```env
|
||||
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.30.0.1
|
||||
```
|
||||
|
||||
When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only
|
||||
if the TCP peer address is inside one of those trusted ranges. Requests coming
|
||||
directly from any other IP ignore forwarded headers and use the socket address.
|
||||
|
||||
Recommended values:
|
||||
|
||||
- Same-host Caddy with systemd: `127.0.0.1,::1`
|
||||
- Docker/Podman bridge gateway: add the exact gateway IP, for example `172.30.0.1`
|
||||
- Docker bridge networks: use a CIDR such as `172.16.0.0/12` only if the exact gateway changes often
|
||||
- Private reverse-proxy networks: add the exact private CIDR used by the proxy
|
||||
|
||||
Warpbox prefers the first public address in `X-Forwarded-For` when a trusted
|
||||
proxy sends a chain. Loopback addresses and trusted proxy addresses are also
|
||||
protected from manual and automatic bans so a bad header setup cannot ban Caddy,
|
||||
the container gateway, or Warpbox itself.
|
||||
|
||||
## Direct Exposure
|
||||
|
||||
If you expose Warpbox directly without Caddy, either leave
|
||||
`WARPBOX_TRUSTED_PROXIES` empty and ensure clients cannot spoof headers at the
|
||||
network edge, or set it to a value that does not include public clients. Direct
|
||||
public exposure is not recommended; use a reverse proxy for TLS and request
|
||||
normalization.
|
||||
|
||||
## Ban Behavior
|
||||
|
||||
Active bans return:
|
||||
|
||||
```text
|
||||
HTTP/1.1 403 Forbidden
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
forbidden
|
||||
```
|
||||
|
||||
Blocked requests are still written to the JSON logs and appear under
|
||||
`/admin/logs` with `source=ban`.
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
AppVersion string
|
||||
Environment string
|
||||
Addr string
|
||||
BaseURL string
|
||||
@@ -22,6 +23,7 @@ type Config struct {
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
TrustedProxies []string
|
||||
JobsEnabled bool
|
||||
CleanupEnabled bool
|
||||
CleanupEvery time.Duration
|
||||
@@ -54,6 +56,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"), "/"),
|
||||
@@ -64,6 +67,7 @@ func Load() (Config, error) {
|
||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
||||
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
||||
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
||||
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
||||
@@ -72,9 +76,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 +101,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 +115,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
|
||||
@@ -178,6 +182,21 @@ func envInt(key string, fallback int) int {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envCSV(key string) []string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
values = append(values, trimmed)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func envMegabytes(key string, fallback float64) int64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
@@ -203,6 +222,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 +277,35 @@ func parseMegabytesFloat(value string) (float64, error) {
|
||||
return sizeMB, nil
|
||||
}
|
||||
|
||||
func parseMegabytesLimitFloat(value string) (float64, error) {
|
||||
sizeMB, err := parseMegabytesFloatAllowNegativeOne(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !validUnlimitedMegabyteLimit(sizeMB) {
|
||||
return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited")
|
||||
}
|
||||
return sizeMB, nil
|
||||
}
|
||||
|
||||
func parseMegabytesFloatAllowNegativeOne(value string) (float64, error) {
|
||||
normalized := strings.TrimSpace(value)
|
||||
normalized = strings.TrimSuffix(normalized, "MB")
|
||||
normalized = strings.TrimSuffix(normalized, "Mb")
|
||||
normalized = strings.TrimSuffix(normalized, "mb")
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
|
||||
sizeMB, err := strconv.ParseFloat(normalized, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err)
|
||||
}
|
||||
return sizeMB, nil
|
||||
}
|
||||
|
||||
func validUnlimitedMegabyteLimit(value float64) bool {
|
||||
return value > 0 || value == -1
|
||||
}
|
||||
|
||||
func megabytesToBytes(sizeMB float64) int64 {
|
||||
return int64(math.Round(sizeMB * 1024 * 1024))
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
@@ -67,6 +72,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 +225,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()
|
||||
@@ -306,6 +415,80 @@ func TestLayeredUploadLimits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchedUploadAppendBypassesDailyBoxCreationCap(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
policy := testPolicy(t, app)
|
||||
policy.AnonymousDailyBoxes = 1
|
||||
policy.AnonymousActiveBoxes = 10
|
||||
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
first := multipartUploadRequest(t, "/api/v1/upload", "file", "first.txt", "hello")
|
||||
first.Header.Set("Accept", "application/json")
|
||||
first.Header.Set(uploadBatchHeader, "sharex-test")
|
||||
firstResponse := httptest.NewRecorder()
|
||||
app.Upload(firstResponse, first)
|
||||
if firstResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("first batched status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
|
||||
}
|
||||
|
||||
second := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
|
||||
second.Header.Set("Accept", "application/json")
|
||||
second.Header.Set(uploadBatchHeader, "sharex-test")
|
||||
secondResponse := httptest.NewRecorder()
|
||||
app.Upload(secondResponse, second)
|
||||
if secondResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("second batched status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
|
||||
}
|
||||
|
||||
third := multipartUploadRequest(t, "/api/v1/upload", "file", "third.txt", "hello")
|
||||
third.Header.Set("Accept", "application/json")
|
||||
thirdResponse := httptest.NewRecorder()
|
||||
app.Upload(thirdResponse, third)
|
||||
if thirdResponse.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("non-batched status = %d, body = %s", thirdResponse.Code, thirdResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchedUploadAppendBypassesActiveBoxCreationCap(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
policy := testPolicy(t, app)
|
||||
policy.AnonymousDailyBoxes = 10
|
||||
policy.AnonymousActiveBoxes = 1
|
||||
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
first := multipartUploadRequest(t, "/api/v1/upload", "file", "first.txt", "hello")
|
||||
first.Header.Set("Accept", "application/json")
|
||||
first.Header.Set(uploadBatchHeader, "active-cap")
|
||||
firstResponse := httptest.NewRecorder()
|
||||
app.Upload(firstResponse, first)
|
||||
if firstResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("first batched status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
|
||||
}
|
||||
|
||||
second := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
|
||||
second.Header.Set("Accept", "application/json")
|
||||
second.Header.Set(uploadBatchHeader, "active-cap")
|
||||
secondResponse := httptest.NewRecorder()
|
||||
app.Upload(secondResponse, second)
|
||||
if secondResponse.Code != http.StatusCreated {
|
||||
t.Fatalf("second batched status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
|
||||
}
|
||||
|
||||
third := multipartUploadRequest(t, "/api/v1/upload", "file", "third.txt", "hello")
|
||||
third.Header.Set("Accept", "application/json")
|
||||
thirdResponse := httptest.NewRecorder()
|
||||
app.Upload(thirdResponse, third)
|
||||
if thirdResponse.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("non-batched status = %d, body = %s", thirdResponse.Code, thirdResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -464,6 +647,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 +695,497 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
|
||||
overview := buildAdminOverview([]services.AdminBox{{
|
||||
ID: "box1",
|
||||
CreatedAt: today,
|
||||
TotalSize: 1024,
|
||||
}}, services.AdminStats{TotalBoxes: 1, TotalFiles: 1, TotalSize: 1024})
|
||||
|
||||
for i, bar := range overview.UploadDays {
|
||||
want := 0
|
||||
if i == len(overview.UploadDays)-1 {
|
||||
want = 100
|
||||
}
|
||||
if bar.Height != want {
|
||||
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
|
||||
}
|
||||
}
|
||||
for i, bar := range overview.StorageDays {
|
||||
want := 0
|
||||
if i == len(overview.StorageDays)-1 {
|
||||
want = 100
|
||||
}
|
||||
if bar.Height != want {
|
||||
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 TestAdminStorageDeleteAction(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||
Provider: services.StorageProviderWebDAV,
|
||||
Name: "DAV",
|
||||
Endpoint: "https://dav.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
RemotePath: "/warpbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
|
||||
deleteRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
|
||||
deleteRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
deleteRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
deleteRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
deleteRequest.SetPathValue("backendID", cfg.ID)
|
||||
deleteResponse := httptest.NewRecorder()
|
||||
app.AdminDeleteStorage(deleteResponse, deleteRequest)
|
||||
if deleteResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminDeleteStorage status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String())
|
||||
}
|
||||
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageDeleteResetsDefaultsAndUserOverrides(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
user, err := app.authService.UserByEmail("admin@example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail returned error: %v", err)
|
||||
}
|
||||
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||
Provider: services.StorageProviderWebDAV,
|
||||
Name: "DAV",
|
||||
Endpoint: "https://dav.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
RemotePath: "/warpbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
settings, err := app.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
settings.UserStorageBackend = cfg.ID
|
||||
if err := app.settingsService.UpdateUploadPolicy(settings); err != nil {
|
||||
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||
}
|
||||
if err := app.authService.SetUserStorageBackend(user.ID, cfg.ID); err != nil {
|
||||
t.Fatalf("SetUserStorageBackend returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", 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"})
|
||||
request.SetPathValue("backendID", cfg.ID)
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminDeleteStorage(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminDeleteStorage status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "Storage+backend+deleted") || !strings.Contains(location, "cleared+1+user+overrides") {
|
||||
t.Fatalf("delete redirect did not include cascade notice: %s", location)
|
||||
}
|
||||
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
|
||||
}
|
||||
nextSettings, err := app.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
if nextSettings.UserStorageBackend != services.StorageBackendLocal {
|
||||
t.Fatalf("UserStorageBackend = %q, want local", nextSettings.UserStorageBackend)
|
||||
}
|
||||
nextUser, err := app.authService.UserByID(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByID returned error: %v", err)
|
||||
}
|
||||
if nextUser.Policy.StorageBackendID != nil {
|
||||
t.Fatalf("user storage override was not cleared: %+v", nextUser.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
logDir := filepath.Join(app.cfg.DataDir, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(logDir, "2026-05-31.log")
|
||||
lines := strings.Join([]string{
|
||||
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
|
||||
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
logsRequest := httptest.NewRequest(http.MethodGet, "/admin/logs?q=box123", nil)
|
||||
logsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
logsResponse := httptest.NewRecorder()
|
||||
app.AdminLogs(logsResponse, logsRequest)
|
||||
if logsResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminLogs status = %d, body = %s", logsResponse.Code, logsResponse.Body.String())
|
||||
}
|
||||
logsBody := logsResponse.Body.String()
|
||||
if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") {
|
||||
t.Fatalf("AdminLogs missing expected log entry: %s", logsBody)
|
||||
}
|
||||
if strings.Contains(logsBody, "172.30.0.1:48358") {
|
||||
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
|
||||
}
|
||||
|
||||
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
||||
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
bansResponse := httptest.NewRecorder()
|
||||
app.AdminBans(bansResponse, bansRequest)
|
||||
if bansResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminBans status = %d, body = %s", bansResponse.Code, bansResponse.Body.String())
|
||||
}
|
||||
if !strings.Contains(bansResponse.Body.String(), "Manual ban") || !strings.Contains(bansResponse.Body.String(), "Auto-ban settings") {
|
||||
t.Fatalf("AdminBans missing ban controls: %s", bansResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanCreateAndUnbanIPBan(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04")
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/bans", strings.NewReader("target=203.0.113.90&reason=test&expires_at="+expiresAt+"&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.AdminCreateBan(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminCreateBan status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
records, err := app.banService.ListBans()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBans returned error: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].Normalized != "203.0.113.90" {
|
||||
t.Fatalf("records = %+v", records)
|
||||
}
|
||||
|
||||
unbanRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/"+records[0].ID+"/unban", strings.NewReader("csrf_token=test-csrf"))
|
||||
unbanRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
unbanRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
unbanRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
unbanRequest.SetPathValue("banID", records[0].ID)
|
||||
unbanResponse := httptest.NewRecorder()
|
||||
app.AdminUnban(unbanResponse, unbanRequest)
|
||||
if unbanResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminUnban status = %d, body = %s", unbanResponse.Code, unbanResponse.Body.String())
|
||||
}
|
||||
if _, ok, err := app.banService.Match("203.0.113.90", time.Now().UTC()); err != nil || ok {
|
||||
t.Fatalf("unbanned Match = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanUpdateBanSettingsAndRules(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/settings", strings.NewReader("auto_ban_enabled=on&auto_ban_duration_hours=48&abuse_window_hours=12&malicious_path_threshold=2&admin_login_failure_threshold=4&user_login_failure_threshold=5&csrf_token=test-csrf"))
|
||||
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
settingsResponse := httptest.NewRecorder()
|
||||
app.AdminBanSettingsPost(settingsResponse, settingsRequest)
|
||||
if settingsResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminBanSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
|
||||
}
|
||||
settings, err := app.banService.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
if !settings.AutoBanEnabled || settings.AutoBanDurationHours != 48 || settings.MaliciousPathThreshold != 2 {
|
||||
t.Fatalf("settings = %+v", settings)
|
||||
}
|
||||
|
||||
rulesRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/rules", strings.NewReader("patterns=%2Fcustom-one%0A%2Fcustom-two&csrf_token=test-csrf"))
|
||||
rulesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rulesRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
rulesRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
rulesResponse := httptest.NewRecorder()
|
||||
app.AdminBanRulesPost(rulesResponse, rulesRequest)
|
||||
if rulesResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminBanRulesPost status = %d, body = %s", rulesResponse.Code, rulesResponse.Body.String())
|
||||
}
|
||||
if pattern, err := app.banService.MaliciousPattern("/x/custom-two"); err != nil || pattern != "/custom-two" {
|
||||
t.Fatalf("MaliciousPattern = %q, %v", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
settings, err := app.banService.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.UserLoginFailureThreshold = 2
|
||||
if err := app.banService.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("email=admin@example.test&password=wrong"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.RemoteAddr = "203.0.113.91:1234"
|
||||
response := httptest.NewRecorder()
|
||||
app.LoginPost(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("LoginPost status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
if _, ok, err := app.banService.Match("203.0.113.91", time.Now().UTC()); err != nil || !ok {
|
||||
t.Fatalf("Match after login failures = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := app.banService.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.AdminLoginFailureThreshold = 2
|
||||
if err := app.banService.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
app.cfg.AdminToken = "correct-token"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("token=wrong"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.RemoteAddr = "203.0.113.92:1234"
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminLoginPost(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("AdminLoginPost status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
if _, ok, err := app.banService.Match("203.0.113.92", time.Now().UTC()); err != nil || !ok {
|
||||
t.Fatalf("Match after admin login failures = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||
t.Helper()
|
||||
user, err := app.authService.UserByID(userID)
|
||||
@@ -534,6 +1211,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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
492
backend/libs/handlers/admin_files.go
Normal file
492
backend/libs/handlers/admin_files.go
Normal file
@@ -0,0 +1,492 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
const adminFilesDefaultPageSize = 50
|
||||
|
||||
var adminFilesPageSizes = []int{25, 50, 100, 200}
|
||||
|
||||
type adminFilesData struct {
|
||||
Stats services.AdminStats
|
||||
Section string
|
||||
PageTitle string
|
||||
Boxes []adminBoxView
|
||||
Query string
|
||||
Sort string
|
||||
Dir string
|
||||
Page int
|
||||
PerPage int
|
||||
PerPageOptions []int
|
||||
TotalPages int
|
||||
Total int
|
||||
RangeFrom int
|
||||
RangeTo int
|
||||
Columns []adminFilesColumn
|
||||
PageLinks []adminFilesPageLink
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevHref string
|
||||
NextHref string
|
||||
}
|
||||
|
||||
// adminFilesQuery captures the listing state that every paginated link must
|
||||
// preserve.
|
||||
type adminFilesQuery struct {
|
||||
Query string
|
||||
Sort string
|
||||
Dir string
|
||||
Per int
|
||||
}
|
||||
|
||||
type adminFilesColumn struct {
|
||||
Label string
|
||||
Href string
|
||||
Sorted bool
|
||||
Ascending bool
|
||||
}
|
||||
|
||||
type adminFilesPageLink struct {
|
||||
Page int
|
||||
Href string
|
||||
Active bool
|
||||
}
|
||||
|
||||
type adminBoxEditData struct {
|
||||
Section string
|
||||
PageTitle string
|
||||
Box adminBoxDetail
|
||||
Files []adminBoxEditFile
|
||||
Notice string
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminBoxDetail struct {
|
||||
ID string
|
||||
Owner string
|
||||
CreatedAt string
|
||||
ExpiresLabel string
|
||||
ExpiresInput string
|
||||
NeverExpires bool
|
||||
MaxDownloads int
|
||||
DownloadCount int
|
||||
FileCount int
|
||||
TotalSize string
|
||||
BackendID string
|
||||
Protected bool
|
||||
Obfuscated bool
|
||||
}
|
||||
|
||||
type adminBoxEditFile struct {
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
ContentType string
|
||||
ThumbnailURL string
|
||||
DownloadURL string
|
||||
HasPreview bool
|
||||
}
|
||||
|
||||
// adminFileRow is the sortable/filterable representation of a box.
|
||||
type adminFileRow struct {
|
||||
ID string
|
||||
Owner string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
FileCount int
|
||||
DownloadCount int
|
||||
MaxDownloads int
|
||||
TotalSize int64
|
||||
TotalSizeLabel string
|
||||
Protected bool
|
||||
Expired bool
|
||||
}
|
||||
|
||||
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := a.uploadService.AdminStats()
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
boxes, err := a.uploadService.AdminBoxes(0)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ownerCache := map[string]string{}
|
||||
rows := make([]adminFileRow, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
rows = append(rows, adminFileRow{
|
||||
ID: box.ID,
|
||||
Owner: a.boxOwnerLabel(box.OwnerID, ownerCache),
|
||||
CreatedAt: box.CreatedAt,
|
||||
ExpiresAt: box.ExpiresAt,
|
||||
FileCount: box.FileCount,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
TotalSize: box.TotalSize,
|
||||
TotalSizeLabel: box.TotalSizeLabel,
|
||||
Protected: box.Protected,
|
||||
Expired: box.Expired,
|
||||
})
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if query != "" {
|
||||
needle := strings.ToLower(query)
|
||||
filtered := rows[:0:0]
|
||||
for _, row := range rows {
|
||||
if strings.Contains(strings.ToLower(row.ID), needle) || strings.Contains(strings.ToLower(row.Owner), needle) {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
rows = filtered
|
||||
}
|
||||
|
||||
sortKey := adminFilesSortKey(r.URL.Query().Get("sort"))
|
||||
dir := r.URL.Query().Get("dir")
|
||||
if dir != "asc" {
|
||||
dir = "desc"
|
||||
}
|
||||
sortAdminFileRows(rows, sortKey, dir)
|
||||
|
||||
perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes)
|
||||
state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage}
|
||||
|
||||
total := len(rows)
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
page := 1
|
||||
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
|
||||
page = parsed
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
start := (page - 1) * perPage
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
|
||||
views := make([]adminBoxView, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
views = append(views, adminBoxView{
|
||||
ID: row.ID,
|
||||
Owner: row.Owner,
|
||||
CreatedAt: row.CreatedAt.Format("Jan 2, 2006 15:04"),
|
||||
ExpiresAt: boxExpiryLabel(row.ExpiresAt, "Jan 2, 2006 15:04"),
|
||||
FileCount: row.FileCount,
|
||||
TotalSizeLabel: row.TotalSizeLabel,
|
||||
DownloadCount: row.DownloadCount,
|
||||
MaxDownloads: row.MaxDownloads,
|
||||
Protected: row.Protected,
|
||||
Expired: row.Expired,
|
||||
})
|
||||
}
|
||||
|
||||
rangeFrom := 0
|
||||
if total > 0 {
|
||||
rangeFrom = start + 1
|
||||
}
|
||||
|
||||
a.renderPage(w, r, http.StatusOK, "admin_files.html", web.PageData{
|
||||
Title: "Admin files",
|
||||
Description: "Manage Warpbox uploads.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminFilesData{
|
||||
Stats: stats,
|
||||
Section: "files",
|
||||
PageTitle: "Files",
|
||||
Boxes: views,
|
||||
Query: query,
|
||||
Sort: sortKey,
|
||||
Dir: dir,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
PerPageOptions: adminFilesPageSizes,
|
||||
TotalPages: totalPages,
|
||||
Total: total,
|
||||
RangeFrom: rangeFrom,
|
||||
RangeTo: end,
|
||||
Columns: adminFilesColumns(state, sortKey, dir),
|
||||
PageLinks: adminFilesPageLinks(state, page, totalPages),
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevHref: adminFilesHref(state, page-1),
|
||||
NextHref: adminFilesHref(state, page+1),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) boxOwnerLabel(ownerID string, cache map[string]string) string {
|
||||
if ownerID == "" {
|
||||
return "Anonymous"
|
||||
}
|
||||
if label, ok := cache[ownerID]; ok {
|
||||
return label
|
||||
}
|
||||
label := "User"
|
||||
if user, err := a.authService.UserByID(ownerID); err == nil {
|
||||
label = user.Email
|
||||
}
|
||||
cache[ownerID] = label
|
||||
return label
|
||||
}
|
||||
|
||||
func adminFilesSortKey(value string) string {
|
||||
switch value {
|
||||
case "id", "owner", "files", "size", "downloads", "expires", "created":
|
||||
return value
|
||||
default:
|
||||
return "created"
|
||||
}
|
||||
}
|
||||
|
||||
func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
|
||||
less := func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
switch sortKey {
|
||||
case "id":
|
||||
return strings.ToLower(a.ID) < strings.ToLower(b.ID)
|
||||
case "owner":
|
||||
return strings.ToLower(a.Owner) < strings.ToLower(b.Owner)
|
||||
case "files":
|
||||
return a.FileCount < b.FileCount
|
||||
case "size":
|
||||
return a.TotalSize < b.TotalSize
|
||||
case "downloads":
|
||||
return a.DownloadCount < b.DownloadCount
|
||||
case "expires":
|
||||
return a.ExpiresAt.Before(b.ExpiresAt)
|
||||
default:
|
||||
return a.CreatedAt.Before(b.CreatedAt)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if dir == "desc" {
|
||||
return less(j, i)
|
||||
}
|
||||
return less(i, j)
|
||||
})
|
||||
}
|
||||
|
||||
func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
|
||||
defs := []struct{ Key, Label string }{
|
||||
{"id", "Box"},
|
||||
{"owner", "Owner"},
|
||||
{"files", "Files"},
|
||||
{"size", "Size"},
|
||||
{"downloads", "Downloads"},
|
||||
{"created", "Created"},
|
||||
{"expires", "Expires"},
|
||||
}
|
||||
columns := make([]adminFilesColumn, 0, len(defs))
|
||||
for _, def := range defs {
|
||||
sorted := sortKey == def.Key
|
||||
nextDir := "asc"
|
||||
if sorted && dir == "asc" {
|
||||
nextDir = "desc"
|
||||
}
|
||||
colState := state
|
||||
colState.Sort = def.Key
|
||||
colState.Dir = nextDir
|
||||
columns = append(columns, adminFilesColumn{
|
||||
Label: def.Label,
|
||||
Href: adminFilesHref(colState, 1),
|
||||
Sorted: sorted,
|
||||
Ascending: dir == "asc",
|
||||
})
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
|
||||
links := make([]adminFilesPageLink, 0, 5)
|
||||
const window = 2
|
||||
for p := page - window; p <= page+window; p++ {
|
||||
if p < 1 || p > totalPages {
|
||||
continue
|
||||
}
|
||||
links = append(links, adminFilesPageLink{
|
||||
Page: p,
|
||||
Href: adminFilesHref(state, p),
|
||||
Active: p == page,
|
||||
})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func adminFilesHref(state adminFilesQuery, page int) string {
|
||||
values := url.Values{}
|
||||
if state.Query != "" {
|
||||
values.Set("q", state.Query)
|
||||
}
|
||||
if state.Sort != "" && state.Sort != "created" {
|
||||
values.Set("sort", state.Sort)
|
||||
}
|
||||
if state.Dir != "" && state.Dir != "desc" {
|
||||
values.Set("dir", state.Dir)
|
||||
}
|
||||
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
|
||||
values.Set("per", strconv.Itoa(state.Per))
|
||||
}
|
||||
if page > 1 {
|
||||
values.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "/admin/files"
|
||||
}
|
||||
return "/admin/files?" + values.Encode()
|
||||
}
|
||||
|
||||
// normalizePageSize parses a requested page size, falling back to def when the
|
||||
// value is missing or not one of the allowed sizes.
|
||||
func normalizePageSize(raw string, def int, allowed []int) int {
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
for _, size := range allowed {
|
||||
if size == parsed {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
var totalSize int64
|
||||
files := make([]adminBoxEditFile, 0, len(box.Files))
|
||||
for _, file := range box.Files {
|
||||
totalSize += file.Size
|
||||
files = append(files, adminBoxEditFile{
|
||||
ID: file.ID,
|
||||
Name: file.Name,
|
||||
Size: helpers.FormatBytes(file.Size),
|
||||
ContentType: file.ContentType,
|
||||
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
||||
DownloadURL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||
HasPreview: file.PreviewKind == "image" || file.PreviewKind == "video",
|
||||
})
|
||||
}
|
||||
|
||||
never := neverExpires(box.ExpiresAt)
|
||||
expiresInput := ""
|
||||
if !never {
|
||||
expiresInput = box.ExpiresAt.UTC().Format("2006-01-02T15:04")
|
||||
}
|
||||
|
||||
cache := map[string]string{}
|
||||
a.renderPage(w, r, http.StatusOK, "admin_box_edit.html", web.PageData{
|
||||
Title: "Edit box",
|
||||
Description: "Edit a Warpbox upload.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminBoxEditData{
|
||||
Section: "files",
|
||||
PageTitle: "Edit box",
|
||||
Notice: r.URL.Query().Get("notice"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
Files: files,
|
||||
Box: adminBoxDetail{
|
||||
ID: box.ID,
|
||||
Owner: a.boxOwnerLabel(box.OwnerID, cache),
|
||||
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04 MST"),
|
||||
ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
|
||||
ExpiresInput: expiresInput,
|
||||
NeverExpires: never,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
DownloadCount: box.DownloadCount,
|
||||
FileCount: len(box.Files),
|
||||
TotalSize: helpers.FormatBytes(totalSize),
|
||||
BackendID: a.uploadService.BoxStorageBackendID(box),
|
||||
Protected: a.uploadService.IsProtected(box),
|
||||
Obfuscated: box.Obfuscate,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminUpdateBox(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
boxID := r.PathValue("boxID")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+read+form", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
var expiresAt time.Time
|
||||
if r.FormValue("never_expires") == "on" {
|
||||
expiresAt = time.Now().UTC().AddDate(100, 0, 0)
|
||||
} else {
|
||||
parsed, err := time.Parse("2006-01-02T15:04", strings.TrimSpace(r.FormValue("expires_at")))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Invalid+expiration+date", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
expiresAt = parsed.UTC()
|
||||
}
|
||||
|
||||
maxDownloads := parsePositiveInt(r.FormValue("max_downloads"))
|
||||
removePassword := r.FormValue("remove_password") == "on"
|
||||
|
||||
if err := a.uploadService.AdminUpdateBox(boxID, expiresAt, maxDownloads, removePassword); err != nil {
|
||||
a.logger.Warn("admin box update failed", "source", "admin", "severity", "warn", "code", 4306, "box_id", boxID, "error", err.Error())
|
||||
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+save+changes", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin box updated", "source", "admin", "severity", "user_activity", "code", 2306, "ip", uploadClientIP(r), "box_id", boxID)
|
||||
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=Changes+saved", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDeleteBoxFile(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
boxID := r.PathValue("boxID")
|
||||
fileID := r.PathValue("fileID")
|
||||
boxDeleted, err := a.uploadService.RemoveFileFromBox(boxID, fileID)
|
||||
if err != nil {
|
||||
a.logger.Warn("admin file delete failed", "source", "admin", "severity", "warn", "code", 4305, "box_id", boxID, "file_id", fileID, "error", err.Error())
|
||||
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+remove+file", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin removed box file", "source", "admin", "severity", "user_activity", "code", 2305, "ip", uploadClientIP(r), "box_id", boxID, "file_id", fileID)
|
||||
if boxDeleted {
|
||||
http.Redirect(w, r, "/admin/files?notice=Box+deleted+(last+file+removed)", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=File+removed", http.StatusSeeOther)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type apiDocsData struct {
|
||||
ShareXExampleURL string
|
||||
ShareXDownloadURL string
|
||||
ShareXFileFieldName string
|
||||
ShareXGroupWindow string
|
||||
}
|
||||
|
||||
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -33,6 +34,7 @@ func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
|
||||
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
|
||||
ShareXFileFieldName: "sharex",
|
||||
ShareXGroupWindow: uploadGroupWindow.String(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -47,11 +49,16 @@ func (a *App) ShareXAnonymousConfig(w http.ResponseWriter, r *http.Request) {
|
||||
"RequestURL": a.cfg.BaseURL + "/api/v1/upload",
|
||||
"Headers": map[string]string{
|
||||
"Accept": "application/json",
|
||||
// Group a multi-file selection (sent as back-to-back requests) into
|
||||
// one box. Remove this header for one box per file.
|
||||
uploadBatchHeader: "sharex",
|
||||
},
|
||||
"Body": "MultipartFormData",
|
||||
"FileFormName": "sharex",
|
||||
"URL": "$json:boxUrl$",
|
||||
"DeletionURL": "$json:manageUrl$",
|
||||
"URL": "{json:boxUrl}",
|
||||
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||
"DeletionURL": "{json:deleteUrl}",
|
||||
"ErrorMessage": "{json:error}",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,8 +119,9 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
|
||||
"boxId": map[string]any{"type": "string"},
|
||||
"boxUrl": map[string]any{"type": "string", "format": "uri"},
|
||||
"zipUrl": map[string]any{"type": "string", "format": "uri"},
|
||||
"thumbnailUrl": map[string]any{"type": "string", "format": "uri", "description": "Thumbnail of the most recently uploaded file (placeholder until generated)."},
|
||||
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
|
||||
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer POST URL for deleting this upload. Returned only at upload time."},
|
||||
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for deleting this upload (GET or POST). Returned only at upload time."},
|
||||
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
|
||||
"files": map[string]any{
|
||||
"type": "array",
|
||||
@@ -125,6 +133,7 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
|
||||
"name": map[string]any{"type": "string"},
|
||||
"size": map[string]any{"type": "string"},
|
||||
"url": map[string]any{"type": "string", "format": "uri"},
|
||||
"thumbnailUrl": map[string]any{"type": "string", "format": "uri"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -16,10 +16,12 @@ type App struct {
|
||||
uploadService *services.UploadService
|
||||
authService *services.AuthService
|
||||
settingsService *services.SettingsService
|
||||
banService *services.BanService
|
||||
rateLimiter *rateLimiter
|
||||
uploadGroups *uploadGrouper
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
@@ -27,7 +29,9 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
||||
uploadService: uploadService,
|
||||
authService: authService,
|
||||
settingsService: settingsService,
|
||||
banService: banService,
|
||||
rateLimiter: newRateLimiter(),
|
||||
uploadGroups: newUploadGrouper(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +60,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)
|
||||
@@ -65,12 +71,35 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
|
||||
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
|
||||
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
|
||||
mux.HandleFunc("GET /admin/logs", a.AdminLogs)
|
||||
mux.HandleFunc("GET /admin/bans", a.AdminBans)
|
||||
mux.HandleFunc("POST /admin/bans", a.AdminCreateBan)
|
||||
mux.HandleFunc("POST /admin/bans/{banID}/unban", a.AdminUnban)
|
||||
mux.HandleFunc("POST /admin/bans/settings", a.AdminBanSettingsPost)
|
||||
mux.HandleFunc("POST /admin/bans/rules", a.AdminBanRulesPost)
|
||||
mux.HandleFunc("POST /admin/bans/rules/{ruleID}/delete", a.AdminBanRuleDelete)
|
||||
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}/disable", a.AdminDisableStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest)
|
||||
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)
|
||||
@@ -79,16 +108,23 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy)
|
||||
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
|
||||
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
|
||||
mux.HandleFunc("GET /admin/boxes/{boxID}/edit", a.AdminEditBox)
|
||||
mux.HandleFunc("POST /admin/boxes/{boxID}/edit", a.AdminUpdateBox)
|
||||
mux.HandleFunc("POST /admin/boxes/{boxID}/files/{fileID}/delete", a.AdminDeleteBoxFile)
|
||||
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
|
||||
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
||||
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
|
||||
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
|
||||
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
||||
// GET variant so ShareX (which issues a GET to the configured DeletionURL)
|
||||
// can delete a box via its secret one-time delete token.
|
||||
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
||||
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
|
||||
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||
mux.HandleFunc("GET /health", a.Health)
|
||||
mux.HandleFunc("GET /healthz", a.Health)
|
||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
@@ -34,6 +35,7 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||
a.logger.Warn("registration rate limited", "source", "auth", "severity", "warn", "code", 4291, "ip", uploadClientIP(r))
|
||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
||||
return
|
||||
}
|
||||
@@ -43,10 +45,11 @@ func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("bootstrap registration failed", "source", "auth", "severity", "warn", "code", 4400, "ip", uploadClientIP(r), "email", r.FormValue("email"), "error", err.Error())
|
||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
|
||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
@@ -60,6 +63,7 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||
a.logger.Warn("login rate limited", "source", "auth", "severity", "warn", "code", 4292, "ip", uploadClientIP(r), "email", r.FormValue("email"))
|
||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
||||
return
|
||||
}
|
||||
@@ -73,12 +77,13 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
|
||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"), "ip", uploadClientIP(r))
|
||||
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
|
||||
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
||||
return
|
||||
}
|
||||
a.setUserSessionCookie(w, r, token)
|
||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
|
||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -86,6 +91,9 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
a.logger.Info("user logout", "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
}
|
||||
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||
_ = a.authService.Logout(cookie.Value)
|
||||
}
|
||||
@@ -106,6 +114,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
invite, err := a.authService.InviteByToken(token)
|
||||
if err != nil {
|
||||
a.logger.Warn("invite accept invalid", "source", "auth", "severity", "warn", "code", 4404, "ip", uploadClientIP(r))
|
||||
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||
return
|
||||
}
|
||||
@@ -115,23 +124,102 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("invite accept failed", "source", "auth", "severity", "warn", "code", 4405, "ip", uploadClientIP(r), "invite_email", invite.Email, "error", err.Error())
|
||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
|
||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "ip", uploadClientIP(r), "invite_email", invite.Email)
|
||||
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())
|
||||
} else {
|
||||
a.logger.Info("api token deleted", "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,13 +233,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
||||
a.logger.Warn("password change failed current password", "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
||||
a.logger.Warn("password change failed", "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("password changed", "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -174,12 +265,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) {
|
||||
|
||||
@@ -82,7 +82,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
FileCount: len(row.Box.Files),
|
||||
Size: row.TotalSizeLabel,
|
||||
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: boxExpiryLabel(row.Box.ExpiresAt, "Jan 2 15:04"),
|
||||
URL: "/d/" + row.Box.ID,
|
||||
})
|
||||
}
|
||||
@@ -113,6 +113,8 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
||||
} else {
|
||||
a.logger.Info("collection created", "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
@@ -127,9 +129,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||
a.logger.Warn("owned box rename failed", "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box renamed", "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -144,13 +148,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
collectionID := r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
a.logger.Warn("owned box move invalid collection", "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
||||
http.Error(w, "collection not found", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||
a.logger.Warn("owned box move failed", "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box moved", "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -160,9 +167,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||
a.logger.Warn("owned box delete failed", "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box deleted", "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ type previewPageData struct {
|
||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("download page missing box", "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("download page unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
|
||||
Title: "Download unavailable",
|
||||
Description: "This Warpbox link is no longer available.",
|
||||
@@ -76,9 +78,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "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,9 +98,17 @@ 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,
|
||||
},
|
||||
})
|
||||
a.logger.Info("download page viewed", "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "ip", uploadClientIP(r), "locked", locked)
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -120,6 +139,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
DownloadURL: view.DownloadURL,
|
||||
},
|
||||
})
|
||||
a.logger.Info("file preview page viewed", "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
||||
}
|
||||
|
||||
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -128,11 +148,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
a.logger.Warn("protected file download blocked", "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
||||
a.logger.Info("file content served", "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "attachment", r.URL.Query().Get("inline") != "1")
|
||||
}
|
||||
|
||||
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -141,13 +163,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 +183,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 {
|
||||
@@ -167,7 +202,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
|
||||
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)
|
||||
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -180,23 +215,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: r.TLS != nil,
|
||||
Expires: box.ExpiresAt,
|
||||
})
|
||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)
|
||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("file request missing box", "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("file request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
|
||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("file request missing file", "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
@@ -206,6 +244,7 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
|
||||
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
||||
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
a.logger.Warn("file object missing", "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -241,14 +280,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("zip request missing box", "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("zip request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
a.logger.Warn("protected zip download blocked", "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -264,6 +306,7 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
|
||||
}
|
||||
a.logger.Info("zip downloaded", "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "ip", uploadClientIP(r), "files", len(box.Files))
|
||||
}
|
||||
|
||||
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||
@@ -294,6 +337,21 @@ func unlockCookieName(boxID string) string {
|
||||
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
|
||||
}
|
||||
|
||||
// neverExpires reports whether a box's expiry is far enough out to be treated as
|
||||
// "forever" (set via the unlimited / -1 expiry option).
|
||||
func neverExpires(t time.Time) bool {
|
||||
return time.Until(t) > 50*365*24*time.Hour
|
||||
}
|
||||
|
||||
// boxExpiryLabel formats a box's expiry with the given layout, rendering
|
||||
// "forever" boxes as "Never" instead of a meaningless far-future date.
|
||||
func boxExpiryLabel(t time.Time, layout string) string {
|
||||
if neverExpires(t) {
|
||||
return "Never"
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
func absoluteURL(r *http.Request, path string) string {
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
|
||||
@@ -31,6 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
|
||||
Description: "Delete this anonymous Warpbox upload.",
|
||||
Data: a.managePageData(box, r.PathValue("token")),
|
||||
})
|
||||
a.logger.Info("anonymous manage page viewed", "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
}
|
||||
|
||||
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -40,10 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
|
||||
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())
|
||||
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.logger.Info("anonymous box deleted", "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -58,10 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("anonymous manage missing box", "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, false
|
||||
}
|
||||
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
|
||||
a.logger.Warn("anonymous manage invalid token", "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, false
|
||||
}
|
||||
@@ -78,7 +82,7 @@ func (a *App) managePageData(box services.Box, token string) managePageData {
|
||||
Token: token,
|
||||
FileCount: len(box.Files),
|
||||
TotalSize: helpers.FormatBytes(totalSize),
|
||||
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
|
||||
ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: a.uploadService.IsProtected(box),
|
||||
|
||||
176
backend/libs/handlers/ogimage.go
Normal file
176
backend/libs/handlers/ogimage.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// Open Graph image dimensions recommended for large summary cards
|
||||
// (Discord, Twitter/X, Slack, etc.).
|
||||
const (
|
||||
ogImageWidth = 1200
|
||||
ogImageHeight = 630
|
||||
ogMaxTiles = 4
|
||||
ogTileGap = 8
|
||||
)
|
||||
|
||||
var ogBackground = color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff}
|
||||
|
||||
// BoxOGImage renders the social-preview image for a box: a collage of up to
|
||||
// four file thumbnails, or a branded placeholder when none are available yet.
|
||||
func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||
return
|
||||
}
|
||||
|
||||
// Never leak thumbnails of a locked, obfuscated box. (Protected-but-not-
|
||||
// obfuscated boxes already show their thumbnails on the download page, so
|
||||
// they may appear here too.)
|
||||
hideContents := a.uploadService.IsProtected(box) && box.Obfuscate
|
||||
|
||||
thumbs := make([]image.Image, 0, ogMaxTiles)
|
||||
if !hideContents {
|
||||
for _, file := range box.Files {
|
||||
if len(thumbs) >= ogMaxTiles {
|
||||
break
|
||||
}
|
||||
if file.Thumbnail == "" && file.ThumbnailObjectKey == "" {
|
||||
continue
|
||||
}
|
||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
img, _, decodeErr := image.Decode(object.Body)
|
||||
object.Body.Close()
|
||||
if decodeErr == nil {
|
||||
thumbs = append(thumbs, img)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(thumbs) == 0 {
|
||||
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||
return
|
||||
}
|
||||
a.serveOGImage(w, r, renderCollage(thumbs))
|
||||
}
|
||||
|
||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
http.Error(w, "could not render preview image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
// Social scrapers fetch this rarely and cache on their side; a modest cache
|
||||
// keeps it fresh as thumbnails finish generating.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
http.ServeContent(w, r, "og-image.jpg", time.Time{}, bytes.NewReader(buf.Bytes()))
|
||||
}
|
||||
|
||||
// ogPlaceholder builds the branded fallback image: the file placeholder icon
|
||||
// centered on the brand background.
|
||||
func (a *App) ogPlaceholder() image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
file, err := os.Open(filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
|
||||
if err != nil {
|
||||
return canvas
|
||||
}
|
||||
defer file.Close()
|
||||
icon, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return canvas
|
||||
}
|
||||
|
||||
// Scale the icon to ~40% of the canvas height and centre it.
|
||||
target := ogImageHeight * 2 / 5
|
||||
b := icon.Bounds()
|
||||
scale := float64(target) / float64(b.Dy())
|
||||
dw := int(float64(b.Dx()) * scale)
|
||||
dh := target
|
||||
x0 := (ogImageWidth - dw) / 2
|
||||
y0 := (ogImageHeight - dh) / 2
|
||||
xdraw.CatmullRom.Scale(canvas, image.Rect(x0, y0, x0+dw, y0+dh), icon, b, xdraw.Over, nil)
|
||||
return canvas
|
||||
}
|
||||
|
||||
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
||||
func renderCollage(thumbs []image.Image) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
cols, rows := collageGrid(len(thumbs))
|
||||
cellW := (ogImageWidth - ogTileGap*(cols+1)) / cols
|
||||
cellH := (ogImageHeight - ogTileGap*(rows+1)) / rows
|
||||
|
||||
i := 0
|
||||
for ry := 0; ry < rows && i < len(thumbs); ry++ {
|
||||
for cx := 0; cx < cols && i < len(thumbs); cx++ {
|
||||
x0 := ogTileGap + cx*(cellW+ogTileGap)
|
||||
y0 := ogTileGap + ry*(cellH+ogTileGap)
|
||||
drawCover(canvas, image.Rect(x0, y0, x0+cellW, y0+cellH), thumbs[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
func collageGrid(n int) (cols, rows int) {
|
||||
switch {
|
||||
case n <= 1:
|
||||
return 1, 1
|
||||
case n == 2:
|
||||
return 2, 1
|
||||
case n == 3:
|
||||
return 3, 1
|
||||
default:
|
||||
return 2, 2
|
||||
}
|
||||
}
|
||||
|
||||
// drawCover scales src to completely fill dst, cropping the overflow (centred),
|
||||
// preserving aspect ratio — the CSS object-fit: cover equivalent.
|
||||
func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) {
|
||||
b := src.Bounds()
|
||||
iw, ih := b.Dx(), b.Dy()
|
||||
if iw <= 0 || ih <= 0 {
|
||||
return
|
||||
}
|
||||
cellAR := float64(cell.Dx()) / float64(cell.Dy())
|
||||
imgAR := float64(iw) / float64(ih)
|
||||
|
||||
var sw, sh int
|
||||
if imgAR > cellAR {
|
||||
// Source is wider than the cell: crop the sides.
|
||||
sh = ih
|
||||
sw = int(float64(ih) * cellAR)
|
||||
} else {
|
||||
// Source is taller: crop top/bottom.
|
||||
sw = iw
|
||||
sh = int(float64(iw) / cellAR)
|
||||
}
|
||||
sx := b.Min.X + (iw-sw)/2
|
||||
sy := b.Min.Y + (ih-sh)/2
|
||||
xdraw.CatmullRom.Scale(dst, cell, src, image.Rect(sx, sy, sx+sw, sy+sh), xdraw.Over, nil)
|
||||
}
|
||||
@@ -14,6 +14,13 @@ type homeData struct {
|
||||
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,6 +47,7 @@ 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.",
|
||||
@@ -50,10 +58,89 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// A negative per-user MaxDays override means unlimited retention.
|
||||
if maxDays < 0 {
|
||||
unlimited = true
|
||||
}
|
||||
}
|
||||
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)})
|
||||
}
|
||||
// Unlimited uploaders can pick "never expires" (sentinel -1) after the ladder.
|
||||
if unlimited {
|
||||
options = append(options, expiryOption{Minutes: -1, Label: "Unlimited (never expires)"})
|
||||
}
|
||||
|
||||
// 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,12 +153,18 @@ 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"
|
||||
if policy.StorageQuotaSet {
|
||||
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
|
||||
}
|
||||
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max."
|
||||
expiryLimit := strconv.Itoa(policy.MaxDays) + " day max."
|
||||
if policy.MaxDays < 0 {
|
||||
expiryLimit = "no expiry limit."
|
||||
}
|
||||
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
const csrfCookieName = "warpbox_csrf"
|
||||
@@ -76,3 +78,29 @@ func randomToken(byteCount int) string {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func (a *App) recordLoginAbuse(r *http.Request, kind, detail string) {
|
||||
if a.banService == nil {
|
||||
return
|
||||
}
|
||||
settings, err := a.banService.Settings()
|
||||
if err != nil || !settings.AutoBanEnabled {
|
||||
return
|
||||
}
|
||||
threshold := settings.UserLoginFailureThreshold
|
||||
if kind == services.AbuseKindAdminLogin {
|
||||
threshold = settings.AdminLoginFailureThreshold
|
||||
}
|
||||
ip := uploadClientIP(r)
|
||||
result, err := a.banService.RecordAbuse(ip, kind, detail, threshold, time.Now().UTC())
|
||||
if err != nil {
|
||||
a.logger.Error("login abuse event failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "kind", kind, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if result.Enabled {
|
||||
a.logger.Warn("login abuse recorded", "source", "ban", "severity", "warn", "code", 4304, "ip", ip, "kind", kind, "count", result.Event.Count)
|
||||
}
|
||||
if result.Triggered {
|
||||
a.logger.Warn("ip auto-banned for login abuse", "source", "ban", "severity", "warn", "code", 4305, "ip", ip, "kind", kind, "ban_id", result.Ban.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
func TestSetStaticCacheHeaders(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"/static/css/app.css": "public, max-age=86400",
|
||||
"/static/js/app.js": "public, max-age=86400",
|
||||
"/static/css/00-base.css": "public, max-age=86400",
|
||||
"/static/js/00-utils.js": "public, max-age=86400",
|
||||
"/static/img/preview.webp": "public, max-age=31536000, immutable",
|
||||
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
|
||||
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
|
||||
|
||||
@@ -16,7 +16,12 @@ 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 {
|
||||
a.logger.Warn("upload rejected invalid bearer token", "source", "user-upload", "severity", "warn", "code", 4010, "ip", uploadClientIP(r), "user_agent", r.UserAgent())
|
||||
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||
settings, err := a.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
@@ -25,24 +30,29 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
||||
a.logger.Warn("anonymous upload rejected disabled", "source", "user-upload", "severity", "warn", "code", 4012, "ip", uploadClientIP(r))
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||
return
|
||||
}
|
||||
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
||||
rateKey := uploadRateKey(r, user, loggedIn)
|
||||
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
||||
if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
||||
a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID)
|
||||
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
||||
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 {
|
||||
a.logger.Warn("upload form parse failed", "source", "user-upload", "severity", "warn", "code", 4000, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
return
|
||||
}
|
||||
@@ -55,42 +65,75 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID = user.ID
|
||||
collectionID = r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
a.logger.Warn("upload rejected invalid collection", "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !isAdminUpload {
|
||||
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))
|
||||
helpers.WriteJSONError(w, status, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
maxDays := parseInt(r.FormValue("max_days"))
|
||||
// Unlimited expiry: admins, or users whose effective MaxDays is negative.
|
||||
unlimitedExpiry := isAdminUpload || effectivePolicy.MaxDays < 0
|
||||
|
||||
rawMaxDays := parseInt(r.FormValue("max_days"))
|
||||
maxDays := rawMaxDays
|
||||
if maxDays <= 0 {
|
||||
maxDays = min(7, effectivePolicy.MaxDays)
|
||||
maxDays = 7
|
||||
if effectivePolicy.MaxDays > 0 && effectivePolicy.MaxDays < maxDays {
|
||||
maxDays = effectivePolicy.MaxDays
|
||||
}
|
||||
if !isAdminUpload && maxDays > effectivePolicy.MaxDays {
|
||||
}
|
||||
if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
|
||||
a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||
|
||||
expiresMinutes := parseInt(r.FormValue("expires_minutes"))
|
||||
// A negative expires_minutes (or max_days) is the "never expires" request.
|
||||
// Only honour it for unlimited uploaders; otherwise it's an invalid value.
|
||||
if expiresMinutes < 0 || rawMaxDays < 0 {
|
||||
if !unlimitedExpiry {
|
||||
a.logger.Warn("upload rejected unlimited expiration", "source", "user-upload", "severity", "warn", "code", 4133, "ip", uploadClientIP(r), "user_id", user.ID)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
expiresMinutes = -1
|
||||
} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
||||
a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
opts := 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,
|
||||
})
|
||||
}
|
||||
result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
|
||||
if policyMessage != "" {
|
||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))
|
||||
helpers.WriteJSONError(w, status, policyMessage)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if !isAdminUpload {
|
||||
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
|
||||
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, boxesAdded); err != nil {
|
||||
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
|
||||
}
|
||||
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
||||
@@ -98,6 +141,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||
a.logger.Info("upload response sent", "source", "user-upload", "severity", "user_activity", "code", 2001, "ip", uploadClientIP(r), "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload)
|
||||
|
||||
if wantsJSON(r) {
|
||||
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||
@@ -109,6 +153,77 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintln(w, result.BoxURL)
|
||||
}
|
||||
|
||||
// createOrAppendBox creates a new box. It only ever appends to an existing box
|
||||
// when the request opts in via the X-Warpbox-Batch header: requests sharing the
|
||||
// same batch value (per account, or per IP for anonymous) within
|
||||
// uploadGroupWindow are folded into one box. Without the header the behaviour is
|
||||
// identical to creating a fresh box every time. Returns the result and how many
|
||||
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
|
||||
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
|
||||
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
|
||||
if batch == "" {
|
||||
if enforceBoxLimits {
|
||||
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
|
||||
return services.UploadResult{}, 0, status, message, nil
|
||||
}
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, opts)
|
||||
if err != nil {
|
||||
return services.UploadResult{}, 0, 0, "", err
|
||||
}
|
||||
return result, 1, 0, "", nil
|
||||
}
|
||||
|
||||
// Group key is scoped to the uploader so batches never cross accounts/IPs.
|
||||
identity := "ip:" + uploadClientIP(r)
|
||||
if loggedIn {
|
||||
identity = "user:" + user.ID
|
||||
}
|
||||
entry := a.uploadGroups.entryFor(identity + "|" + batch)
|
||||
|
||||
// Hold the per-key lock across the whole create/append so concurrent batched
|
||||
// uploads serialise into the same box instead of racing.
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
|
||||
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
|
||||
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
|
||||
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
|
||||
// Re-attach the manage/delete URLs from the box's creation so every
|
||||
// upload in the batch returns a working deletion URL.
|
||||
result.ManageURL = entry.manageURL
|
||||
result.DeleteURL = entry.deleteURL
|
||||
entry.at = time.Now()
|
||||
return result, 0, 0, "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if enforceBoxLimits {
|
||||
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
|
||||
return services.UploadResult{}, 0, status, message, nil
|
||||
}
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, opts)
|
||||
if err != nil {
|
||||
return services.UploadResult{}, 0, 0, "", err
|
||||
}
|
||||
entry.boxID = result.BoxID
|
||||
entry.manageURL = result.ManageURL
|
||||
entry.deleteURL = result.DeleteURL
|
||||
entry.at = time.Now()
|
||||
return result, 1, 0, "", nil
|
||||
}
|
||||
|
||||
// batchBoxMatches guards that a batched append only ever touches a box owned by
|
||||
// the same uploader (account for logged-in users, creator IP for anonymous).
|
||||
func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool {
|
||||
if loggedIn {
|
||||
return box.OwnerID == user.ID
|
||||
}
|
||||
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
|
||||
}
|
||||
|
||||
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
||||
if len(files) == 0 {
|
||||
return 0, ""
|
||||
@@ -127,19 +242,9 @@ 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 {
|
||||
return http.StatusTooManyRequests, "anonymous daily box limit reached"
|
||||
}
|
||||
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "active box limit could not be checked"
|
||||
}
|
||||
if activeBoxes+1 > policy.ActiveBoxes {
|
||||
return http.StatusTooManyRequests, "anonymous active box limit reached"
|
||||
}
|
||||
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
||||
return status, message
|
||||
}
|
||||
@@ -150,19 +255,9 @@ 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 {
|
||||
return http.StatusTooManyRequests, "daily box limit reached"
|
||||
}
|
||||
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "active box limit could not be checked"
|
||||
}
|
||||
if activeBoxes+1 > policy.ActiveBoxes {
|
||||
return http.StatusTooManyRequests, "active box limit reached"
|
||||
}
|
||||
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "storage quota could not be checked"
|
||||
@@ -176,6 +271,42 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func (a *App) checkBoxCreationPolicy(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy) (int, string) {
|
||||
now := time.Now().UTC()
|
||||
if !loggedIn {
|
||||
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||
}
|
||||
if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
|
||||
return http.StatusTooManyRequests, "anonymous daily box limit reached"
|
||||
}
|
||||
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "active box limit could not be checked"
|
||||
}
|
||||
if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
|
||||
return http.StatusTooManyRequests, "anonymous active box limit reached"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
usage, err := a.settingsService.UsageForUser(user.ID, now)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||
}
|
||||
if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
|
||||
return http.StatusTooManyRequests, "daily box limit reached"
|
||||
}
|
||||
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "active box limit could not be checked"
|
||||
}
|
||||
if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
|
||||
return http.StatusTooManyRequests, "active box limit reached"
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
|
||||
now := time.Now().UTC()
|
||||
if loggedIn {
|
||||
@@ -210,6 +341,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
|
||||
}
|
||||
@@ -220,7 +354,10 @@ func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fall
|
||||
}
|
||||
|
||||
func uploadClientIP(r *http.Request) string {
|
||||
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
if ip, ok := services.ClientIPFromContext(r); ok {
|
||||
return ip
|
||||
}
|
||||
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
|
||||
}
|
||||
|
||||
func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
|
||||
|
||||
76
backend/libs/handlers/upload_group.go
Normal file
76
backend/libs/handlers/upload_group.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// uploadGroupWindow is how long after a batched upload a follow-up upload with
|
||||
// the same X-Warpbox-Batch value (and same account/IP) is folded into the same
|
||||
// box. ShareX sends a multi-file selection as separate back-to-back requests;
|
||||
// the batch header lets it land them in one box.
|
||||
const uploadGroupWindow = 20 * time.Second
|
||||
|
||||
// uploadBatchHeader is the opt-in request header. Without it, uploads behave
|
||||
// exactly as before (one box per request). With it, requests sharing the same
|
||||
// value (per account/IP) within uploadGroupWindow are grouped into one box.
|
||||
const uploadBatchHeader = "X-Warpbox-Batch"
|
||||
|
||||
// uploadGroupPruneInterval is how often entryFor drops stale entries so the map
|
||||
// can't grow without bound (one key per account/IP + batch value otherwise).
|
||||
const uploadGroupPruneInterval = 5 * time.Minute
|
||||
|
||||
// uploadGrouper tracks the most recent box per batch key so opt-in batched
|
||||
// uploads land in a single box. Each key has its own lock, which also serialises
|
||||
// that key's concurrent uploads so they append to the same box instead of racing
|
||||
// to create several.
|
||||
type uploadGrouper struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*uploadGroupEntry
|
||||
lastPrune time.Time
|
||||
}
|
||||
|
||||
type uploadGroupEntry struct {
|
||||
mu sync.Mutex
|
||||
boxID string
|
||||
manageURL string
|
||||
deleteURL string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func newUploadGrouper() *uploadGrouper {
|
||||
return &uploadGrouper{entries: make(map[string]*uploadGroupEntry)}
|
||||
}
|
||||
|
||||
func (g *uploadGrouper) entryFor(key string) *uploadGroupEntry {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.pruneLocked(time.Now())
|
||||
entry, ok := g.entries[key]
|
||||
if !ok {
|
||||
entry = &uploadGroupEntry{at: time.Now()}
|
||||
g.entries[key] = entry
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// pruneLocked drops entries whose last use is well past the grouping window so
|
||||
// the map stays bounded to recently-active keys. Callers must hold g.mu. Entries
|
||||
// currently in use are kept to avoid removing one a request is about to
|
||||
// populate.
|
||||
func (g *uploadGrouper) pruneLocked(now time.Time) {
|
||||
if now.Sub(g.lastPrune) < uploadGroupPruneInterval {
|
||||
return
|
||||
}
|
||||
g.lastPrune = now
|
||||
for key, entry := range g.entries {
|
||||
if !entry.mu.TryLock() {
|
||||
continue
|
||||
}
|
||||
stale := now.Sub(entry.at) > 2*uploadGroupWindow
|
||||
entry.mu.Unlock()
|
||||
if stale {
|
||||
delete(g.entries, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
backend/libs/handlers/upload_group_test.go
Normal file
24
backend/libs/handlers/upload_group_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUploadGroupPrunesFailedEntries(t *testing.T) {
|
||||
g := newUploadGrouper()
|
||||
entry := g.entryFor("ip:203.0.113.1|failed")
|
||||
entry.mu.Lock()
|
||||
entry.at = time.Now().Add(-3 * uploadGroupWindow)
|
||||
entry.mu.Unlock()
|
||||
g.lastPrune = time.Now().Add(-uploadGroupPruneInterval)
|
||||
|
||||
_ = g.entryFor("ip:203.0.113.1|next")
|
||||
|
||||
if _, ok := g.entries["ip:203.0.113.1|failed"]; ok {
|
||||
t.Fatalf("stale failed entry was not pruned")
|
||||
}
|
||||
if _, ok := g.entries["ip:203.0.113.1|next"]; !ok {
|
||||
t.Fatalf("new entry was not created")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -212,7 +213,12 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
service.Close()
|
||||
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() {
|
||||
banService, err := services.NewBanService(service.DB())
|
||||
if err != nil {
|
||||
service.Close()
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() {
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
|
||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -32,8 +32,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
uploadService.Close()
|
||||
return nil, err
|
||||
}
|
||||
stopJobs := jobs.StartAll(cfg, logger, uploadService)
|
||||
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService)
|
||||
banService, err := services.NewBanService(uploadService.DB())
|
||||
if err != nil {
|
||||
uploadService.Close()
|
||||
return nil, err
|
||||
}
|
||||
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
|
||||
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService)
|
||||
|
||||
router := http.NewServeMux()
|
||||
app.RegisterRoutes(router)
|
||||
@@ -44,7 +49,9 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
middleware.RequestID,
|
||||
middleware.SecurityHeaders,
|
||||
middleware.Gzip,
|
||||
middleware.ClientIP(cfg.TrustedProxies),
|
||||
middleware.Logger(logger),
|
||||
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
||||
)
|
||||
|
||||
server := &http.Server{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
|
||||
func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) job {
|
||||
return job{
|
||||
name: "cleanup",
|
||||
enabled: cfg.CleanupEnabled,
|
||||
@@ -22,10 +22,24 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
|
||||
if cleaned > 0 {
|
||||
logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
|
||||
}
|
||||
if banService != nil {
|
||||
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Warn("ban evidence cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4203, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if cleanedEvents > 0 {
|
||||
logger.Info("ban evidence cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2203, "cleaned", cleanedEvents)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -16,14 +16,14 @@ type job struct {
|
||||
run func()
|
||||
}
|
||||
|
||||
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) func() {
|
||||
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) func() {
|
||||
if !cfg.JobsEnabled {
|
||||
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
|
||||
return func() {}
|
||||
}
|
||||
|
||||
stops := []func(){
|
||||
start(newCleanupJob(cfg, logger, uploadService), logger),
|
||||
start(newCleanupJob(cfg, logger, uploadService, banService), logger),
|
||||
start(newThumbnailsJob(cfg, logger, uploadService), logger),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
64
backend/libs/middleware/bans.go
Normal file
64
backend/libs/middleware/bans.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, ok := services.ClientIPFromContext(r)
|
||||
if !ok {
|
||||
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||
r = services.WithClientIP(r, ip)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
protectedProxy := services.IsProtectedProxyIP(ip, trustedProxies)
|
||||
|
||||
if bans != nil && !protectedProxy {
|
||||
if matched, ok, err := bans.Match(ip, now); err != nil {
|
||||
logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error())
|
||||
} else if ok {
|
||||
logger.Warn("banned request blocked", "source", "ban", "severity", "warn", "code", 4030, "ip", ip, "ban_id", matched.Ban.ID, "target", matched.Ban.Normalized, "path", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("forbidden\n"))
|
||||
return
|
||||
}
|
||||
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
logger.Error("ban settings load failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "error", err.Error())
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if !settings.AutoBanEnabled {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil {
|
||||
logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error())
|
||||
} else if pattern != "" {
|
||||
if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, settings.MaliciousPathThreshold, now); err != nil {
|
||||
logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error())
|
||||
} else if result.Enabled {
|
||||
logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count)
|
||||
if result.Triggered {
|
||||
logger.Warn("ip auto-banned for malicious path", "source", "ban", "severity", "warn", "code", 4303, "ip", ip, "ban_id", result.Ban.ID, "path", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("forbidden\n"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
168
backend/libs/middleware/bans_test.go
Normal file
168
backend/libs/middleware/bans_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func TestBansMiddlewareBlocksActiveBan(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
if _, err := bans.CreateManualBan("203.0.113.20", "test", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("CreateManualBan returned error: %v", err)
|
||||
}
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:6070"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.20")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || response.Body.String() != "forbidden\n" {
|
||||
t.Fatalf("blocked response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareAllowsNonBannedIP(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
called := false
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.RemoteAddr = "203.0.113.21:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if !called || response.Code != http.StatusOK {
|
||||
t.Fatalf("allowed response = called %v code %d", called, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareAutoBansMaliciousPaths(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.MaliciousPathThreshold = 3
|
||||
if err := bans.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||
request.RemoteAddr = "203.0.113.22:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if i < 2 && response.Code == http.StatusForbidden {
|
||||
t.Fatalf("request %d blocked before threshold", i+1)
|
||||
}
|
||||
if i == 2 && response.Code != http.StatusForbidden {
|
||||
t.Fatalf("request 3 status = %d, want forbidden", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareSkipsAutoBanWhenDisabled(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||
request.RemoteAddr = "203.0.113.23:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code == http.StatusForbidden {
|
||||
t.Fatalf("request %d was blocked while auto-ban disabled", i+1)
|
||||
}
|
||||
}
|
||||
if _, ok, err := bans.Match("203.0.113.23", time.Now().UTC()); err != nil || ok {
|
||||
t.Fatalf("disabled auto-ban Match = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareDoesNotBlockProtectedProxyIP(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
if _, err := bans.CreateManualBan("127.0.0.1", "bad historical ban", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("CreateManualBan returned error: %v", err)
|
||||
}
|
||||
called := false
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if !called || response.Code != http.StatusOK {
|
||||
t.Fatalf("protected proxy response = called %v code %d", called, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareDoesNotAutoBanProtectedProxyIP(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.MaliciousPathThreshold = 1
|
||||
if err := bans.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||
request.RemoteAddr = "127.0.0.1:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code == http.StatusForbidden {
|
||||
t.Fatalf("protected proxy was auto-banned")
|
||||
}
|
||||
if _, ok, err := bans.Match("127.0.0.1", time.Now().UTC()); err != nil || ok {
|
||||
t.Fatalf("protected proxy Match = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newMiddlewareBanService(t *testing.T) *services.BanService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
upload, err := services.NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewUploadService returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := upload.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
bans, err := services.NewBanService(upload.DB())
|
||||
if err != nil {
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return bans
|
||||
}
|
||||
16
backend/libs/middleware/client_ip.go
Normal file
16
backend/libs/middleware/client_ip.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func ClientIP(trustedProxies []string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||
next.ServeHTTP(w, services.WithClientIP(r, ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
@@ -38,6 +40,10 @@ func Logger(logger *slog.Logger) Middleware {
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
ip, ok := services.ClientIPFromContext(r)
|
||||
if !ok {
|
||||
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
|
||||
}
|
||||
|
||||
logger.Info("http request",
|
||||
"source", "http",
|
||||
@@ -49,6 +55,7 @@ func Logger(logger *slog.Logger) Middleware {
|
||||
"bytes", recorder.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
"ip", ip,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
@@ -423,6 +574,38 @@ func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
|
||||
return s.saveUser(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) ClearStorageBackendOverrides(backendID string) (int, error) {
|
||||
backendID = strings.TrimSpace(backendID)
|
||||
if backendID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cleared := 0
|
||||
err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||
users := tx.Bucket(usersBucket)
|
||||
return users.ForEach(func(key, value []byte) error {
|
||||
var user User
|
||||
if err := json.Unmarshal(value, &user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Policy.StorageBackendID == nil || *user.Policy.StorageBackendID != backendID {
|
||||
return nil
|
||||
}
|
||||
user.Policy.StorageBackendID = nil
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
next, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := users.Put(key, next); err != nil {
|
||||
return err
|
||||
}
|
||||
cleared++
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return cleared, err
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) {
|
||||
if err := validateUserPolicy(policy); err != nil {
|
||||
return User{}, err
|
||||
@@ -673,6 +856,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,26 +888,26 @@ 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")
|
||||
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 && *policy.StorageQuotaMB != -1 {
|
||||
return fmt.Errorf("storage quota override must be 0/positive or -1 for unlimited")
|
||||
}
|
||||
if policy.MaxDays != nil && *policy.MaxDays <= 0 {
|
||||
return fmt.Errorf("expiration override must be positive")
|
||||
if policy.MaxDays != nil && *policy.MaxDays <= 0 && *policy.MaxDays != -1 {
|
||||
return fmt.Errorf("expiration override must be positive or -1 for unlimited")
|
||||
}
|
||||
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 {
|
||||
return fmt.Errorf("daily box override must be positive")
|
||||
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 && *policy.DailyBoxes != -1 {
|
||||
return fmt.Errorf("daily box override must be positive or -1 for unlimited")
|
||||
}
|
||||
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 {
|
||||
return fmt.Errorf("active box override must be positive")
|
||||
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 && *policy.ActiveBoxes != -1 {
|
||||
return fmt.Errorf("active box override must be positive or -1 for unlimited")
|
||||
}
|
||||
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 {
|
||||
return fmt.Errorf("short-window request override must be positive")
|
||||
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 {
|
||||
return fmt.Errorf("short-window request override must be positive or -1 for unlimited")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
571
backend/libs/services/bans.go
Normal file
571
backend/libs/services/bans.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var (
|
||||
bansBucket = []byte("bans")
|
||||
abuseEventsBucket = []byte("abuse_events")
|
||||
banRulesBucket = []byte("ban_rules")
|
||||
banSettingsBucket = []byte("ban_settings")
|
||||
banSettingsKey = []byte("settings")
|
||||
defaultBanRulesSeed = []byte("default_rules_seeded")
|
||||
)
|
||||
|
||||
const (
|
||||
BanSourceManual = "manual"
|
||||
BanSourceAuto = "auto"
|
||||
|
||||
AbuseKindMaliciousPath = "malicious_path"
|
||||
AbuseKindAdminLogin = "admin_login_failure"
|
||||
AbuseKindUserLogin = "user_login_failure"
|
||||
)
|
||||
|
||||
var defaultMaliciousPathRules = []string{
|
||||
"/wp-admin",
|
||||
"/.env",
|
||||
"/.git/config",
|
||||
"/phpmyadmin",
|
||||
"/wp-login.php",
|
||||
"/xmlrpc.php",
|
||||
"/config.php",
|
||||
"/vendor/phpunit",
|
||||
".env",
|
||||
"backup",
|
||||
"dump.sql",
|
||||
}
|
||||
|
||||
var ErrBanNotFound = errors.New("ban not found")
|
||||
|
||||
type BanService struct {
|
||||
db *bbolt.DB
|
||||
}
|
||||
|
||||
type BanSettings struct {
|
||||
AutoBanEnabled bool `json:"autoBanEnabled"`
|
||||
AutoBanDurationHours int `json:"autoBanDurationHours"`
|
||||
MaliciousPathThreshold int `json:"maliciousPathThreshold"`
|
||||
AdminLoginFailureThreshold int `json:"adminLoginFailureThreshold"`
|
||||
UserLoginFailureThreshold int `json:"userLoginFailureThreshold"`
|
||||
AbuseWindowHours int `json:"abuseWindowHours"`
|
||||
}
|
||||
|
||||
type BanRecord struct {
|
||||
ID string `json:"id"`
|
||||
Target string `json:"target"`
|
||||
Normalized string `json:"normalized"`
|
||||
Reason string `json:"reason"`
|
||||
Source string `json:"source"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
UnbannedAt *time.Time `json:"unbannedAt,omitempty"`
|
||||
LastMatchedAt *time.Time `json:"lastMatchedAt,omitempty"`
|
||||
}
|
||||
|
||||
type BanRule struct {
|
||||
ID string `json:"id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AbuseEvent struct {
|
||||
Key string `json:"key"`
|
||||
IP string `json:"ip"`
|
||||
Kind string `json:"kind"`
|
||||
Count int `json:"count"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type MatchedBan struct {
|
||||
Ban BanRecord
|
||||
IP string
|
||||
}
|
||||
|
||||
type AbuseResult struct {
|
||||
Event AbuseEvent
|
||||
Ban BanRecord
|
||||
Triggered bool
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewBanService(db *bbolt.DB) (*BanService, error) {
|
||||
service := &BanService{db: db}
|
||||
err := db.Update(func(tx *bbolt.Tx) error {
|
||||
for _, bucket := range [][]byte{bansBucket, abuseEventsBucket, banRulesBucket, banSettingsBucket} {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Bucket(banSettingsBucket).Get(banSettingsKey) == nil {
|
||||
data, err := json.Marshal(DefaultBanSettings())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Bucket(banSettingsBucket).Put(banSettingsKey, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rules := tx.Bucket(banRulesBucket)
|
||||
if rules.Get(defaultBanRulesSeed) == nil {
|
||||
now := time.Now().UTC()
|
||||
for _, pattern := range defaultMaliciousPathRules {
|
||||
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
|
||||
data, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rules.Put([]byte(rule.ID), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rules.Put(defaultBanRulesSeed, []byte("1")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return service, err
|
||||
}
|
||||
|
||||
func DefaultBanSettings() BanSettings {
|
||||
return BanSettings{
|
||||
AutoBanEnabled: false,
|
||||
AutoBanDurationHours: 24,
|
||||
MaliciousPathThreshold: 3,
|
||||
AdminLoginFailureThreshold: 10,
|
||||
UserLoginFailureThreshold: 30,
|
||||
AbuseWindowHours: 24,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BanService) Settings() (BanSettings, error) {
|
||||
settings := DefaultBanSettings()
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
data := tx.Bucket(banSettingsBucket).Get(banSettingsKey)
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
settings = withBanSettingDefaults(settings)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return BanSettings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *BanService) UpdateSettings(settings BanSettings) error {
|
||||
settings = withBanSettingDefaults(settings)
|
||||
if settings.AutoBanDurationHours <= 0 || settings.MaliciousPathThreshold <= 0 ||
|
||||
settings.AdminLoginFailureThreshold <= 0 || settings.UserLoginFailureThreshold <= 0 ||
|
||||
settings.AbuseWindowHours <= 0 {
|
||||
return fmt.Errorf("ban settings must be positive")
|
||||
}
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(banSettingsBucket).Put(banSettingsKey, data)
|
||||
})
|
||||
}
|
||||
|
||||
func withBanSettingDefaults(settings BanSettings) BanSettings {
|
||||
defaults := DefaultBanSettings()
|
||||
if settings.AutoBanDurationHours <= 0 {
|
||||
settings.AutoBanDurationHours = defaults.AutoBanDurationHours
|
||||
}
|
||||
if settings.MaliciousPathThreshold <= 0 {
|
||||
settings.MaliciousPathThreshold = defaults.MaliciousPathThreshold
|
||||
}
|
||||
if settings.AdminLoginFailureThreshold <= 0 {
|
||||
settings.AdminLoginFailureThreshold = defaults.AdminLoginFailureThreshold
|
||||
}
|
||||
if settings.UserLoginFailureThreshold <= 0 {
|
||||
settings.UserLoginFailureThreshold = defaults.UserLoginFailureThreshold
|
||||
}
|
||||
if settings.AbuseWindowHours <= 0 {
|
||||
settings.AbuseWindowHours = defaults.AbuseWindowHours
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *BanService) CreateManualBan(target, reason, createdBy string, expiresAt time.Time) (BanRecord, error) {
|
||||
return s.createBan(target, reason, BanSourceManual, createdBy, expiresAt, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (s *BanService) createBan(target, reason, source, createdBy string, expiresAt, now time.Time) (BanRecord, error) {
|
||||
normalized, err := NormalizeBanTarget(target)
|
||||
if err != nil {
|
||||
return BanRecord{}, err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return BanRecord{}, fmt.Errorf("ban reason is required")
|
||||
}
|
||||
if !expiresAt.After(now) {
|
||||
return BanRecord{}, fmt.Errorf("ban expiration must be in the future")
|
||||
}
|
||||
record := BanRecord{
|
||||
ID: randomID(12),
|
||||
Target: strings.TrimSpace(target),
|
||||
Normalized: normalized,
|
||||
Reason: reason,
|
||||
Source: source,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ExpiresAt: expiresAt.UTC(),
|
||||
}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return BanRecord{}, err
|
||||
}
|
||||
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(bansBucket).Put([]byte(record.ID), data)
|
||||
})
|
||||
return record, err
|
||||
}
|
||||
|
||||
func NormalizeBanTarget(target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("ban target is required")
|
||||
}
|
||||
if strings.Contains(target, "/") {
|
||||
_, network, err := net.ParseCIDR(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid CIDR target")
|
||||
}
|
||||
return network.String(), nil
|
||||
}
|
||||
ip := net.ParseIP(target)
|
||||
if ip == nil {
|
||||
return "", fmt.Errorf("invalid IP target")
|
||||
}
|
||||
return ip.String(), nil
|
||||
}
|
||||
|
||||
func (s *BanService) ListBans() ([]BanRecord, error) {
|
||||
records := []BanRecord{}
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(bansBucket).ForEach(func(_, value []byte) error {
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(value, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
records = append(records, record)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].CreatedAt.After(records[j].CreatedAt)
|
||||
})
|
||||
return records, err
|
||||
}
|
||||
|
||||
func (s *BanService) Unban(id string, now time.Time) error {
|
||||
id = strings.TrimSpace(id)
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(bansBucket)
|
||||
data := bucket.Get([]byte(id))
|
||||
if data == nil {
|
||||
return ErrBanNotFound
|
||||
}
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
now = now.UTC()
|
||||
record.UnbannedAt = &now
|
||||
record.UpdatedAt = now
|
||||
next, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.Put([]byte(id), next)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil {
|
||||
return MatchedBan{}, false, nil
|
||||
}
|
||||
now = now.UTC()
|
||||
var matched BanRecord
|
||||
var matchedKey []byte
|
||||
// Read-only scan first: the common case (no match) only takes a concurrent
|
||||
// read transaction, instead of grabbing the single bbolt write lock on every
|
||||
// request that flows through the ban middleware.
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(bansBucket)
|
||||
return bucket.ForEach(func(key, value []byte) error {
|
||||
if matched.ID != "" {
|
||||
return nil
|
||||
}
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(value, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) {
|
||||
return nil
|
||||
}
|
||||
matched = record
|
||||
matchedKey = append([]byte(nil), key...) // key bytes are only valid within the txn
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil || matched.ID == "" {
|
||||
return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err
|
||||
}
|
||||
|
||||
// On a hit, record the match time in a short write transaction.
|
||||
matched.LastMatchedAt = &now
|
||||
matched.UpdatedAt = now
|
||||
_ = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(bansBucket)
|
||||
data := bucket.Get(matchedKey)
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return nil
|
||||
}
|
||||
record.LastMatchedAt = &now
|
||||
record.UpdatedAt = now
|
||||
next, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return bucket.Put(matchedKey, next)
|
||||
})
|
||||
return MatchedBan{Ban: matched, IP: ip}, true, nil
|
||||
}
|
||||
|
||||
func (r BanRecord) Active(now time.Time) bool {
|
||||
return r.UnbannedAt == nil && r.ExpiresAt.After(now.UTC())
|
||||
}
|
||||
|
||||
func (r BanRecord) Status(now time.Time) string {
|
||||
switch {
|
||||
case r.UnbannedAt != nil:
|
||||
return "unbanned"
|
||||
case !r.ExpiresAt.After(now.UTC()):
|
||||
return "expired"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func banTargetMatches(target string, ip net.IP) bool {
|
||||
if strings.Contains(target, "/") {
|
||||
if _, network, err := net.ParseCIDR(target); err == nil {
|
||||
return network.Contains(ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
targetIP := net.ParseIP(target)
|
||||
return targetIP != nil && targetIP.Equal(ip)
|
||||
}
|
||||
|
||||
func (s *BanService) ListRules() ([]BanRule, error) {
|
||||
rules := []BanRule{}
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(banRulesBucket).ForEach(func(key, value []byte) error {
|
||||
if string(key) == string(defaultBanRulesSeed) {
|
||||
return nil
|
||||
}
|
||||
var rule BanRule
|
||||
if err := json.Unmarshal(value, &rule); err != nil {
|
||||
return err
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
return strings.ToLower(rules[i].Pattern) < strings.ToLower(rules[j].Pattern)
|
||||
})
|
||||
return rules, err
|
||||
}
|
||||
|
||||
func (s *BanService) SaveRules(patterns []string, now time.Time) error {
|
||||
now = now.UTC()
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(banRulesBucket)
|
||||
deleteKeys := [][]byte{}
|
||||
if err := bucket.ForEach(func(key, _ []byte) error {
|
||||
if string(key) == string(defaultBanRulesSeed) {
|
||||
return nil
|
||||
}
|
||||
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range deleteKeys {
|
||||
if err := bucket.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" || seen[strings.ToLower(pattern)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(pattern)] = true
|
||||
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
|
||||
data, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bucket.Put([]byte(rule.ID), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BanService) DeleteRule(id string) error {
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(banRulesBucket).Delete([]byte(strings.TrimSpace(id)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BanService) MaliciousPattern(path string) (string, error) {
|
||||
if shouldSkipMaliciousPath(path) {
|
||||
return "", nil
|
||||
}
|
||||
rules, err := s.ListRules()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lowerPath := strings.ToLower(path)
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled && strings.Contains(lowerPath, strings.ToLower(rule.Pattern)) {
|
||||
return rule.Pattern, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func shouldSkipMaliciousPath(path string) bool {
|
||||
return path == "/health" || path == "/healthz" || path == "/api/v1/health" || strings.HasPrefix(path, "/static/")
|
||||
}
|
||||
|
||||
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return AbuseResult{}, err
|
||||
}
|
||||
if !settings.AutoBanEnabled {
|
||||
return AbuseResult{Enabled: false}, nil
|
||||
}
|
||||
if threshold <= 0 {
|
||||
return AbuseResult{Enabled: true}, nil
|
||||
}
|
||||
now = now.UTC()
|
||||
window := time.Duration(settings.AbuseWindowHours) * time.Hour
|
||||
key := abuseKey(ip, kind)
|
||||
var event AbuseEvent
|
||||
var triggered bool
|
||||
var ban BanRecord
|
||||
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(abuseEventsBucket)
|
||||
data := bucket.Get([]byte(key))
|
||||
if data != nil {
|
||||
if err := json.Unmarshal(data, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if data == nil || now.Sub(event.FirstSeen) > window {
|
||||
event = AbuseEvent{Key: key, IP: ip, Kind: kind, FirstSeen: now}
|
||||
}
|
||||
event.Count++
|
||||
event.LastSeen = now
|
||||
event.Detail = detail
|
||||
next, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bucket.Put([]byte(key), next); err != nil {
|
||||
return err
|
||||
}
|
||||
triggered = event.Count >= threshold
|
||||
return nil
|
||||
})
|
||||
if err != nil || !triggered {
|
||||
return AbuseResult{Event: event, Triggered: false, Enabled: true}, err
|
||||
}
|
||||
if matched, ok, err := s.Match(ip, now); err != nil {
|
||||
return AbuseResult{}, err
|
||||
} else if ok {
|
||||
return AbuseResult{Event: event, Ban: matched.Ban, Triggered: true, Enabled: true}, nil
|
||||
}
|
||||
reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail)
|
||||
ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now)
|
||||
if err != nil {
|
||||
return AbuseResult{}, err
|
||||
}
|
||||
return AbuseResult{Event: event, Ban: ban, Triggered: true, Enabled: true}, nil
|
||||
}
|
||||
|
||||
func (s *BanService) CleanupAbuseEvents(now time.Time) (int, error) {
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cutoff := now.UTC().Add(-time.Duration(settings.AbuseWindowHours) * time.Hour)
|
||||
cleaned := 0
|
||||
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(abuseEventsBucket)
|
||||
deleteKeys := [][]byte{}
|
||||
if err := bucket.ForEach(func(key, value []byte) error {
|
||||
var event AbuseEvent
|
||||
if err := json.Unmarshal(value, &event); err != nil {
|
||||
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||
return nil
|
||||
}
|
||||
if event.LastSeen.Before(cutoff) {
|
||||
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range deleteKeys {
|
||||
if err := bucket.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
cleaned++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return cleaned, err
|
||||
}
|
||||
|
||||
func abuseKey(ip, kind string) string {
|
||||
return kind + ":" + strings.TrimSpace(ip)
|
||||
}
|
||||
128
backend/libs/services/bans_test.go
Normal file
128
backend/libs/services/bans_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBanServiceMatchesIPAndCIDR(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
ipBan, err := bans.createBan("203.0.113.5", "single IP", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan IP returned error: %v", err)
|
||||
}
|
||||
cidrBan, err := bans.createBan("198.51.100.0/24", "CIDR", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan CIDR returned error: %v", err)
|
||||
}
|
||||
|
||||
if matched, ok, err := bans.Match("203.0.113.5", now); err != nil || !ok || matched.Ban.ID != ipBan.ID {
|
||||
t.Fatalf("Match IP = %+v, %v, %v", matched, ok, err)
|
||||
}
|
||||
if matched, ok, err := bans.Match("198.51.100.42", now); err != nil || !ok || matched.Ban.ID != cidrBan.ID {
|
||||
t.Fatalf("Match CIDR = %+v, %v, %v", matched, ok, err)
|
||||
}
|
||||
if _, ok, err := bans.Match("192.0.2.1", now); err != nil || ok {
|
||||
t.Fatalf("Match unrelated = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanServiceIgnoresExpiredAndUnbanned(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
expired, err := bans.createBan("203.0.113.6", "expired", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan expired returned error: %v", err)
|
||||
}
|
||||
if _, ok, err := bans.Match("203.0.113.6", now.Add(2*time.Hour)); err != nil || ok {
|
||||
t.Fatalf("expired Match = %v, %v", ok, err)
|
||||
}
|
||||
active, err := bans.createBan("203.0.113.7", "active", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan active returned error: %v", err)
|
||||
}
|
||||
if err := bans.Unban(active.ID, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("Unban returned error: %v", err)
|
||||
}
|
||||
if _, ok, err := bans.Match("203.0.113.7", now.Add(2*time.Minute)); err != nil || ok {
|
||||
t.Fatalf("unbanned Match = %v, %v", ok, err)
|
||||
}
|
||||
if expired.Status(now.Add(2*time.Hour)) != "expired" {
|
||||
t.Fatalf("expired status = %q", expired.Status(now.Add(2*time.Hour)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanServiceAutoBanThresholdsAndDisabled(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
if result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now); err != nil || result.Enabled {
|
||||
t.Fatalf("disabled RecordAbuse = %+v, %v", result, err)
|
||||
}
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
if err := bans.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(time.Duration(i)*time.Minute))
|
||||
if err != nil || result.Triggered {
|
||||
t.Fatalf("RecordAbuse before threshold = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(3*time.Minute))
|
||||
if err != nil || !result.Triggered || result.Ban.ID == "" {
|
||||
t.Fatalf("RecordAbuse threshold = %+v, %v", result, err)
|
||||
}
|
||||
again, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(4*time.Minute))
|
||||
if err != nil || !again.Triggered || again.Ban.ID != result.Ban.ID {
|
||||
t.Fatalf("RecordAbuse duplicate = %+v, %v", again, err)
|
||||
}
|
||||
records, err := bans.ListBans()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBans returned error: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("ban count = %d, want 1", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanServiceMaliciousPathRules(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
if pattern, err := bans.MaliciousPattern("/foo/.ENV"); err != nil || pattern == "" {
|
||||
t.Fatalf("MaliciousPattern .env = %q, %v", pattern, err)
|
||||
}
|
||||
if pattern, err := bans.MaliciousPattern("/static/.env"); err != nil || pattern != "" {
|
||||
t.Fatalf("MaliciousPattern static = %q, %v", pattern, err)
|
||||
}
|
||||
if err := bans.SaveRules([]string{"/custom-probe"}, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("SaveRules returned error: %v", err)
|
||||
}
|
||||
if pattern, err := bans.MaliciousPattern("/x/CUSTOM-probe"); err != nil || pattern != "/custom-probe" {
|
||||
t.Fatalf("MaliciousPattern custom = %q, %v", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBanService(t *testing.T) *BanService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewUploadService returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := upload.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
bans, err := NewBanService(upload.DB())
|
||||
if err != nil {
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return bans
|
||||
}
|
||||
140
backend/libs/services/proxy.go
Normal file
140
backend/libs/services/proxy.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type clientIPContextKey struct{}
|
||||
|
||||
func WithClientIP(r *http.Request, ip string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), clientIPContextKey{}, ip))
|
||||
}
|
||||
|
||||
func ClientIPFromContext(r *http.Request) (string, bool) {
|
||||
ip, ok := r.Context().Value(clientIPContextKey{}).(string)
|
||||
return ip, ok && ip != ""
|
||||
}
|
||||
|
||||
// ClientIP resolves the effective client IP. When trustedProxies is empty,
|
||||
// forwarded headers are trusted for easy reverse-proxy/container defaults.
|
||||
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
|
||||
remoteIP := IPOnly(remoteAddr)
|
||||
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {
|
||||
if ip := firstForwardedIP(forwardedFor); ip != "" {
|
||||
return IPOnly(ip)
|
||||
}
|
||||
if ip := strings.TrimSpace(realIP); ip != "" {
|
||||
return IPOnly(ip)
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func IPOnly(remoteAddr string) string {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = splitHost
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func IsProtectedProxyIP(ip string, trustedProxies []string) bool {
|
||||
parsed := net.ParseIP(IPOnly(ip))
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
if parsed.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
return remoteTrusted(parsed.String(), trustedProxies)
|
||||
}
|
||||
|
||||
func ProtectedBanTarget(target string, trustedProxies []string) bool {
|
||||
normalized, err := NormalizeBanTarget(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(normalized, "/") {
|
||||
return IsProtectedProxyIP(normalized, trustedProxies)
|
||||
}
|
||||
_, targetNet, err := net.ParseCIDR(normalized)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if targetNet.Contains(net.ParseIP("127.0.0.1")) || targetNet.Contains(net.ParseIP("::1")) {
|
||||
return true
|
||||
}
|
||||
for _, trusted := range trustedProxies {
|
||||
trusted = strings.TrimSpace(trusted)
|
||||
if trusted == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trusted, "/") {
|
||||
if _, trustedNet, err := net.ParseCIDR(trusted); err == nil && networksOverlap(targetNet, trustedNet) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(IPOnly(trusted)); ip != nil && targetNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstForwardedIP(forwardedFor string) string {
|
||||
var fallback string
|
||||
for _, part := range strings.Split(forwardedFor, ",") {
|
||||
ip := IPOnly(part)
|
||||
if net.ParseIP(ip) == nil {
|
||||
continue
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = ip
|
||||
}
|
||||
if isExternalIP(ip) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func remoteTrusted(remoteIP string, trustedProxies []string) bool {
|
||||
parsed := net.ParseIP(remoteIP)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
for _, trusted := range trustedProxies {
|
||||
trusted = strings.TrimSpace(trusted)
|
||||
if trusted == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trusted, "/") {
|
||||
if _, network, err := net.ParseCIDR(trusted); err == nil && network.Contains(parsed) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(trusted); ip != nil && ip.Equal(parsed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isExternalIP(ip string) bool {
|
||||
parsed := net.ParseIP(IPOnly(ip))
|
||||
return parsed != nil &&
|
||||
!parsed.IsLoopback() &&
|
||||
!parsed.IsPrivate() &&
|
||||
!parsed.IsLinkLocalUnicast() &&
|
||||
!parsed.IsLinkLocalMulticast() &&
|
||||
!parsed.IsUnspecified()
|
||||
}
|
||||
|
||||
func networksOverlap(a, b *net.IPNet) bool {
|
||||
return a.Contains(b.IP) || b.Contains(a.IP)
|
||||
}
|
||||
74
backend/libs/services/proxy_test.go
Normal file
74
backend/libs/services/proxy_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClientIPTrustsForwardedHeadersByDefault(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "203.0.113.10, 10.0.0.2", "198.51.100.2", nil)
|
||||
if ip != "203.0.113.10" {
|
||||
t.Fatalf("ClientIP = %q, want forwarded IP", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPUsesTrustedProxyCIDRs(t *testing.T) {
|
||||
trusted := []string{"127.0.0.1", "172.16.0.0/12"}
|
||||
ip := ClientIP("172.20.0.4:6070", "203.0.113.11", "", trusted)
|
||||
if ip != "203.0.113.11" {
|
||||
t.Fatalf("trusted ClientIP = %q", ip)
|
||||
}
|
||||
spoofed := ClientIP("198.51.100.20:6070", "203.0.113.12", "203.0.113.13", trusted)
|
||||
if spoofed != "198.51.100.20" {
|
||||
t.Fatalf("untrusted ClientIP = %q, want remote addr", spoofed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPFallsBackToRealIP(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "", "203.0.113.14", nil)
|
||||
if ip != "203.0.113.14" {
|
||||
t.Fatalf("ClientIP = %q, want real IP", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPStripsPortsFromForwardedHeaders(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "203.0.113.15:49152", "", nil)
|
||||
if ip != "203.0.113.15" {
|
||||
t.Fatalf("ClientIP = %q, want forwarded IP without port", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPPrefersExternalForwardedAddress(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "172.30.0.1, 198.51.100.30", "", nil)
|
||||
if ip != "198.51.100.30" {
|
||||
t.Fatalf("ClientIP = %q, want public forwarded IP", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPOnlyHandlesIPv6HostPort(t *testing.T) {
|
||||
ip := IPOnly("[2001:db8::1]:6070")
|
||||
if ip != "2001:db8::1" {
|
||||
t.Fatalf("IPOnly = %q, want IPv6 address without port", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedProxyIP(t *testing.T) {
|
||||
trusted := []string{"127.0.0.1", "172.30.0.1", "10.88.0.0/16"}
|
||||
for _, ip := range []string{"127.0.0.1:48122", "172.30.0.1", "10.88.0.12"} {
|
||||
if !IsProtectedProxyIP(ip, trusted) {
|
||||
t.Fatalf("IsProtectedProxyIP(%q) = false, want true", ip)
|
||||
}
|
||||
}
|
||||
if IsProtectedProxyIP("203.0.113.50", trusted) {
|
||||
t.Fatalf("external IP treated as protected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedBanTarget(t *testing.T) {
|
||||
trusted := []string{"172.30.0.1", "10.88.0.0/16"}
|
||||
for _, target := range []string{"127.0.0.1", "172.30.0.1", "172.30.0.0/24", "10.88.12.0/24"} {
|
||||
if !ProtectedBanTarget(target, trusted) {
|
||||
t.Fatalf("ProtectedBanTarget(%q) = false, want true", target)
|
||||
}
|
||||
}
|
||||
if ProtectedBanTarget("203.0.113.0/24", trusted) {
|
||||
t.Fatalf("external target treated as protected")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -169,13 +169,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 {
|
||||
@@ -233,6 +233,29 @@ func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) erro
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SettingsService) ResetStorageBackend(backendID string) (bool, bool, error) {
|
||||
backendID = strings.TrimSpace(backendID)
|
||||
if backendID == "" || backendID == StorageBackendLocal {
|
||||
return false, false, nil
|
||||
}
|
||||
settings, err := s.UploadPolicy()
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
resetAnonymous := settings.AnonymousStorageBackend == backendID
|
||||
resetUser := settings.UserStorageBackend == backendID
|
||||
if !resetAnonymous && !resetUser {
|
||||
return false, false, nil
|
||||
}
|
||||
if resetAnonymous {
|
||||
settings.AnonymousStorageBackend = StorageBackendLocal
|
||||
}
|
||||
if resetUser {
|
||||
settings.UserStorageBackend = StorageBackendLocal
|
||||
}
|
||||
return resetAnonymous, resetUser, s.UpdateUploadPolicy(settings)
|
||||
}
|
||||
|
||||
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
|
||||
key := usageKey(subjectType, subject, now)
|
||||
var record UsageRecord
|
||||
@@ -369,14 +392,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 +444,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 +480,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"
|
||||
}
|
||||
|
||||
@@ -453,19 +506,3 @@ func normalizeBackendID(id string) string {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func ClientIP(remoteAddr, forwardedFor string) string {
|
||||
if forwardedFor != "" {
|
||||
parts := strings.Split(forwardedFor, ",")
|
||||
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
host := remoteAddr
|
||||
if strings.Contains(remoteAddr, ":") {
|
||||
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = splitHost
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
124
backend/libs/services/storage_local.go
Normal file
124
backend/libs/services/storage_local.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type localStorageBackend struct {
|
||||
id string
|
||||
root string
|
||||
}
|
||||
|
||||
func (b localStorageBackend) ID() string { return b.id }
|
||||
func (b localStorageBackend) Type() string { return StorageBackendLocal }
|
||||
|
||||
func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
source, err := os.Open(path)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Delete(_ context.Context, key string) error {
|
||||
path, err := b.path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error {
|
||||
path, err := b.path(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Usage(_ context.Context) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (b localStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b localStorageBackend) path(key string) (string, error) {
|
||||
key = filepath.Clean(strings.TrimPrefix(key, "/"))
|
||||
if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) {
|
||||
return "", fmt.Errorf("invalid storage key")
|
||||
}
|
||||
path := filepath.Join(b.root, key)
|
||||
root, err := filepath.Abs(b.root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("invalid storage key")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
18
backend/libs/services/storage_readcloser.go
Normal file
18
backend/libs/services/storage_readcloser.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package services
|
||||
|
||||
import "io"
|
||||
|
||||
type joinedReadCloser struct {
|
||||
io.ReadCloser
|
||||
close func()
|
||||
}
|
||||
|
||||
func closeWith(source io.ReadCloser, close func()) io.ReadCloser {
|
||||
return joinedReadCloser{ReadCloser: source, close: close}
|
||||
}
|
||||
|
||||
func (c joinedReadCloser) Close() error {
|
||||
err := c.ReadCloser.Close()
|
||||
c.close()
|
||||
return err
|
||||
}
|
||||
113
backend/libs/services/storage_s3.go
Normal file
113
backend/libs/services/storage_s3.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type s3StorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) {
|
||||
endpoint := normalizeS3Endpoint(cfg.Endpoint)
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
BucketLookup: s3BucketLookup(cfg.PathStyle),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s3StorageBackend{cfg: cfg, client: client}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
|
||||
|
||||
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
info, err := object.Stat()
|
||||
if err != nil {
|
||||
object.Close()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/"
|
||||
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
|
||||
for object := range objects {
|
||||
if object.Err != nil {
|
||||
return object.Err
|
||||
}
|
||||
if err := b.Delete(ctx, object.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
var total int64
|
||||
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
||||
if object.Err != nil {
|
||||
return 0, object.Err
|
||||
}
|
||||
total += object.Size
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Test(ctx context.Context) error {
|
||||
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
|
||||
}
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func s3BucketLookup(pathStyle bool) minio.BucketLookupType {
|
||||
if pathStyle {
|
||||
return minio.BucketLookupPath
|
||||
}
|
||||
return minio.BucketLookupAuto
|
||||
}
|
||||
|
||||
func normalizeS3Endpoint(endpoint string) string {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
|
||||
return parsed.Host
|
||||
}
|
||||
return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
|
||||
}
|
||||
200
backend/libs/services/storage_sftp.go
Normal file
200
backend/libs/services/storage_sftp.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type sftpStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b sftpStorageBackend) Type() string { return StorageBackendSFTP }
|
||||
|
||||
func (b sftpStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
if err := client.MkdirAll(path.Dir(remotePath)); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := client.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
source, err := client.Open(remotePath)
|
||||
if err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(prefix)
|
||||
if err := client.RemoveDirectory(remotePath); err == nil || os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
walker := client.Walk(remotePath)
|
||||
paths := make([]string, 0)
|
||||
for walker.Step() {
|
||||
if walker.Err() != nil {
|
||||
return walker.Err()
|
||||
}
|
||||
paths = append(paths, walker.Path())
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
|
||||
for _, item := range paths {
|
||||
if err := client.Remove(item); err != nil {
|
||||
_ = client.RemoveDirectory(item)
|
||||
}
|
||||
}
|
||||
_ = client.RemoveDirectory(remotePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
client, closer, err := b.client()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
walker := client.Walk(cleanRemoteRoot(b.cfg.RemotePath))
|
||||
for walker.Step() {
|
||||
if walker.Err() != nil {
|
||||
return 0, walker.Err()
|
||||
}
|
||||
info := walker.Stat()
|
||||
if info != nil && !info.IsDir() {
|
||||
total += info.Size()
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) client() (*sftp.Client, func(), error) {
|
||||
auth := make([]ssh.AuthMethod, 0, 2)
|
||||
if b.cfg.PrivateKey != "" {
|
||||
signer, err := ssh.ParsePrivateKey([]byte(b.cfg.PrivateKey))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
auth = append(auth, ssh.PublicKeys(signer))
|
||||
}
|
||||
if b.cfg.Password != "" {
|
||||
auth = append(auth, ssh.Password(b.cfg.Password))
|
||||
}
|
||||
if len(auth) == 0 {
|
||||
return nil, nil, fmt.Errorf("sftp password or private key is required")
|
||||
}
|
||||
hostKeyCallback, err := b.hostKeyCallback()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sshClient, err := ssh.Dial("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), &ssh.ClientConfig{
|
||||
User: b.cfg.Username,
|
||||
Auth: auth,
|
||||
HostKeyCallback: hostKeyCallback,
|
||||
Timeout: 15 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
client, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
sshClient.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return client, func() {
|
||||
client.Close()
|
||||
sshClient.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) hostKeyCallback() (ssh.HostKeyCallback, error) {
|
||||
if strings.TrimSpace(b.cfg.HostKey) == "" {
|
||||
return ssh.InsecureIgnoreHostKey(), nil
|
||||
}
|
||||
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(b.cfg.HostKey)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid sftp host public key: %w", err)
|
||||
}
|
||||
return ssh.FixedHostKey(key), nil
|
||||
}
|
||||
|
||||
func (b sftpStorageBackend) remotePath(key string) string {
|
||||
return path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
|
||||
}
|
||||
176
backend/libs/services/storage_smb.go
Normal file
176
backend/libs/services/storage_smb.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hirochachacha/go-smb2"
|
||||
)
|
||||
|
||||
type smbStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b smbStorageBackend) Type() string { return StorageBackendSMB }
|
||||
|
||||
func (b smbStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath := b.remotePath(key)
|
||||
if err := share.MkdirAll(path.Dir(remotePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := share.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
_, err = io.Copy(target, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
source, err := share.Open(b.remotePath(key))
|
||||
if err != nil {
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
stat, err := source.Stat()
|
||||
if err != nil {
|
||||
source.Close()
|
||||
closer()
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := share.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = share.RemoveAll(b.remotePath(prefix))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
share, closer, err := b.share()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer closer()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return smbUsage(share, cleanRemoteRoot(b.cfg.RemotePath))
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) share() (*smb2.Share, func(), error) {
|
||||
conn, err := net.DialTimeout("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), 15*time.Second)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dialer := &smb2.Dialer{
|
||||
Initiator: &smb2.NTLMInitiator{
|
||||
User: b.cfg.Username,
|
||||
Password: b.cfg.Password,
|
||||
Domain: b.cfg.Domain,
|
||||
},
|
||||
}
|
||||
session, err := dialer.Dial(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
share, err := session.Mount(b.cfg.Share)
|
||||
if err != nil {
|
||||
session.Logoff()
|
||||
conn.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return share, func() {
|
||||
share.Umount()
|
||||
session.Logoff()
|
||||
conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b smbStorageBackend) remotePath(key string) string {
|
||||
return strings.TrimPrefix(path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)), "/")
|
||||
}
|
||||
|
||||
func smbUsage(share *smb2.Share, root string) (int64, error) {
|
||||
root = strings.TrimPrefix(root, "/")
|
||||
entries, err := share.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, entry := range entries {
|
||||
item := path.Join(root, entry.Name())
|
||||
if entry.IsDir() {
|
||||
size, err := smbUsage(share, item)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += size
|
||||
continue
|
||||
}
|
||||
total += entry.Size()
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
424
backend/libs/services/storage_speed.go
Normal file
424
backend/libs/services/storage_speed.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var storageSpeedTestsBucket = []byte("storage_speed_tests")
|
||||
|
||||
const (
|
||||
StorageSpeedModeSmall = "small"
|
||||
StorageSpeedModeBig = "big"
|
||||
StorageSpeedModeMixed = "mixed"
|
||||
StorageSpeedModeCustom = "custom"
|
||||
|
||||
StorageSpeedStatusRunning = "running"
|
||||
StorageSpeedStatusDone = "done"
|
||||
StorageSpeedStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type StorageSpeedTest struct {
|
||||
ID string `json:"id"`
|
||||
BackendID string `json:"backendId"`
|
||||
BackendName string `json:"backendName"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
Stage string `json:"stage"`
|
||||
ProgressPercent int `json:"progressPercent"`
|
||||
CustomFileCount int `json:"customFileCount,omitempty"`
|
||||
CustomFileSizeMB float64 `json:"customFileSizeMb,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
BytesWritten int64 `json:"bytesWritten"`
|
||||
BytesRead int64 `json:"bytesRead"`
|
||||
FilesWritten int `json:"filesWritten"`
|
||||
WriteDurationMS int64 `json:"writeDurationMs"`
|
||||
ReadDurationMS int64 `json:"readDurationMs"`
|
||||
DeleteDurationMS int64 `json:"deleteDurationMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (t StorageSpeedTest) ModeLabel() string {
|
||||
switch t.Mode {
|
||||
case StorageSpeedModeSmall:
|
||||
return "Many small files"
|
||||
case StorageSpeedModeBig:
|
||||
return "One big file"
|
||||
case StorageSpeedModeMixed:
|
||||
return "Average mix"
|
||||
case StorageSpeedModeCustom:
|
||||
return "Custom"
|
||||
default:
|
||||
return t.Mode
|
||||
}
|
||||
}
|
||||
|
||||
func (t StorageSpeedTest) StartedLabel() string {
|
||||
if t.StartedAt.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.StartedAt.Format("Jan 2, 15:04:05")
|
||||
}
|
||||
|
||||
func (t StorageSpeedTest) FinishedLabel() string {
|
||||
if t.FinishedAt.IsZero() {
|
||||
return "Still running"
|
||||
}
|
||||
return t.FinishedAt.Format("Jan 2, 15:04:05")
|
||||
}
|
||||
|
||||
func (t StorageSpeedTest) TotalSizeLabel() string {
|
||||
return FormatMegabytesFromBytes(max(t.BytesWritten, t.BytesRead))
|
||||
}
|
||||
|
||||
func (t StorageSpeedTest) WriteSpeedLabel() string {
|
||||
return speedLabel(t.BytesWritten, t.WriteDurationMS)
|
||||
}
|
||||
|
||||
func (t StorageSpeedTest) ReadSpeedLabel() string {
|
||||
return speedLabel(t.BytesRead, t.ReadDurationMS)
|
||||
}
|
||||
|
||||
func speedLabel(bytes int64, durationMS int64) string {
|
||||
if bytes <= 0 || durationMS <= 0 {
|
||||
return "n/a"
|
||||
}
|
||||
mb := float64(bytes) / 1024 / 1024
|
||||
seconds := float64(durationMS) / 1000
|
||||
value := math.Round((mb/seconds)*100) / 100
|
||||
return fmt.Sprintf("%.2f MB/s", value)
|
||||
}
|
||||
|
||||
func (s *StorageService) StartSpeedTest(backendID, mode string) (StorageSpeedTest, error) {
|
||||
return s.StartSpeedTestWithOptions(backendID, StorageSpeedTestOptions{Mode: mode})
|
||||
}
|
||||
|
||||
type StorageSpeedTestOptions struct {
|
||||
Mode string
|
||||
CustomFileCount int
|
||||
CustomFileSizeMB float64
|
||||
}
|
||||
|
||||
func (s *StorageService) StartSpeedTestWithOptions(backendID string, options StorageSpeedTestOptions) (StorageSpeedTest, error) {
|
||||
cfg, err := s.BackendConfig(backendID)
|
||||
if err != nil {
|
||||
return StorageSpeedTest{}, err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return StorageSpeedTest{}, fmt.Errorf("storage backend is disabled")
|
||||
}
|
||||
if !cfg.LastTestSuccess {
|
||||
return StorageSpeedTest{}, fmt.Errorf("run a successful connection test before testing speed")
|
||||
}
|
||||
mode := normalizeSpeedTestMode(options.Mode)
|
||||
if mode == StorageSpeedModeCustom {
|
||||
if err := validateCustomSpeedTest(options.CustomFileCount, options.CustomFileSizeMB); err != nil {
|
||||
return StorageSpeedTest{}, err
|
||||
}
|
||||
}
|
||||
test := StorageSpeedTest{
|
||||
ID: randomID(10),
|
||||
BackendID: cfg.ID,
|
||||
BackendName: cfg.Name,
|
||||
Mode: mode,
|
||||
Status: StorageSpeedStatusRunning,
|
||||
Stage: "queued",
|
||||
CustomFileCount: options.CustomFileCount,
|
||||
CustomFileSizeMB: options.CustomFileSizeMB,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.saveSpeedTest(test); err != nil {
|
||||
return StorageSpeedTest{}, err
|
||||
}
|
||||
return test, nil
|
||||
}
|
||||
|
||||
func (s *StorageService) RunSpeedTest(ctx context.Context, testID string) {
|
||||
test, err := s.speedTest(testID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.runSpeedTest(ctx, &test); err != nil {
|
||||
test.Status = StorageSpeedStatusFailed
|
||||
test.Error = err.Error()
|
||||
test.FinishedAt = time.Now().UTC()
|
||||
if test.Stage == "" || test.Stage == "queued" {
|
||||
test.Stage = "failed"
|
||||
}
|
||||
_ = s.saveSpeedTest(test)
|
||||
return
|
||||
}
|
||||
test.Status = StorageSpeedStatusDone
|
||||
test.Stage = "complete"
|
||||
test.ProgressPercent = 100
|
||||
test.FinishedAt = time.Now().UTC()
|
||||
_ = s.saveSpeedTest(test)
|
||||
}
|
||||
|
||||
func (s *StorageService) ListSpeedTests(backendID string, limit int) ([]StorageSpeedTest, error) {
|
||||
var tests []StorageSpeedTest
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(storageSpeedTestsBucket)
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
return bucket.ForEach(func(_, value []byte) error {
|
||||
var test StorageSpeedTest
|
||||
if err := json.Unmarshal(value, &test); err != nil {
|
||||
return err
|
||||
}
|
||||
if backendID == "" || test.BackendID == backendID {
|
||||
tests = append(tests, test)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(tests, func(i, j int) bool {
|
||||
return tests[i].StartedAt.After(tests[j].StartedAt)
|
||||
})
|
||||
if limit > 0 && len(tests) > limit {
|
||||
tests = tests[:limit]
|
||||
}
|
||||
return tests, nil
|
||||
}
|
||||
|
||||
func (s *StorageService) speedTest(id string) (StorageSpeedTest, error) {
|
||||
var test StorageSpeedTest
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
data := tx.Bucket(storageSpeedTestsBucket).Get([]byte(id))
|
||||
if data == nil {
|
||||
return fmt.Errorf("speed test not found")
|
||||
}
|
||||
return json.Unmarshal(data, &test)
|
||||
})
|
||||
return test, err
|
||||
}
|
||||
|
||||
func (s *StorageService) saveSpeedTest(test StorageSpeedTest) error {
|
||||
data, err := json.Marshal(test)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(storageSpeedTestsBucket).Put([]byte(test.ID), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StorageService) runSpeedTest(ctx context.Context, test *StorageSpeedTest) error {
|
||||
backend, err := s.Backend(test.BackendID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := createSpeedTestFiles(test)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(files.Root)
|
||||
keys := make([]string, 0, len(files.Files))
|
||||
defer func() {
|
||||
for _, key := range keys {
|
||||
_ = backend.Delete(context.Background(), key)
|
||||
}
|
||||
}()
|
||||
|
||||
writeStart := time.Now()
|
||||
for i, file := range files.Files {
|
||||
key := fmt.Sprintf(".warpbox-speed-test/%s/%03d.bin", test.ID, i)
|
||||
source, err := os.Open(file.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = backend.Put(ctx, key, source, file.Size, "application/octet-stream")
|
||||
source.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys = append(keys, key)
|
||||
test.BytesWritten += file.Size
|
||||
test.FilesWritten++
|
||||
updateSpeedProgress(test, "writing", i+1, len(files.Files), 0, 45)
|
||||
_ = s.saveSpeedTest(*test)
|
||||
}
|
||||
test.WriteDurationMS = time.Since(writeStart).Milliseconds()
|
||||
_ = s.saveSpeedTest(*test)
|
||||
|
||||
readStart := time.Now()
|
||||
for i, key := range keys {
|
||||
object, err := backend.Get(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
read, err := io.Copy(io.Discard, object.Body)
|
||||
object.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
test.BytesRead += read
|
||||
updateSpeedProgress(test, "reading", i+1, len(keys), 45, 90)
|
||||
_ = s.saveSpeedTest(*test)
|
||||
}
|
||||
test.ReadDurationMS = time.Since(readStart).Milliseconds()
|
||||
_ = s.saveSpeedTest(*test)
|
||||
|
||||
deleteStart := time.Now()
|
||||
for i, key := range keys {
|
||||
if err := backend.Delete(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
updateSpeedProgress(test, "cleaning up", i+1, len(keys), 90, 100)
|
||||
_ = s.saveSpeedTest(*test)
|
||||
}
|
||||
test.DeleteDurationMS = time.Since(deleteStart).Milliseconds()
|
||||
keys = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateSpeedProgress(test *StorageSpeedTest, stage string, done, total, start, end int) {
|
||||
test.Stage = stage
|
||||
if total <= 0 {
|
||||
test.ProgressPercent = start
|
||||
return
|
||||
}
|
||||
span := end - start
|
||||
progress := start + int(math.Round(float64(span)*float64(done)/float64(total)))
|
||||
if progress < 0 {
|
||||
progress = 0
|
||||
}
|
||||
if progress > 100 {
|
||||
progress = 100
|
||||
}
|
||||
test.ProgressPercent = progress
|
||||
}
|
||||
|
||||
type speedTestFile struct {
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type speedTestFiles struct {
|
||||
Root string
|
||||
Files []speedTestFile
|
||||
}
|
||||
|
||||
func createSpeedTestFiles(test *StorageSpeedTest) (speedTestFiles, error) {
|
||||
plan, err := speedTestPlan(test)
|
||||
if err != nil {
|
||||
return speedTestFiles{}, err
|
||||
}
|
||||
root, err := os.MkdirTemp("", "warpbox-speed-test-*")
|
||||
if err != nil {
|
||||
return speedTestFiles{}, err
|
||||
}
|
||||
files := speedTestFiles{Root: root, Files: make([]speedTestFile, 0, len(plan))}
|
||||
for i, size := range plan {
|
||||
path := filepath.Join(root, fmt.Sprintf("%03d.bin", i))
|
||||
if err := writeMockFile(path, size, byte(65+(i%23))); err != nil {
|
||||
os.RemoveAll(root)
|
||||
return speedTestFiles{}, err
|
||||
}
|
||||
files.Files = append(files.Files, speedTestFile{Path: path, Size: size})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func speedTestPlan(test *StorageSpeedTest) ([]int64, error) {
|
||||
mode := normalizeSpeedTestMode(test.Mode)
|
||||
if mode == StorageSpeedModeCustom {
|
||||
if err := validateCustomSpeedTest(test.CustomFileCount, test.CustomFileSizeMB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := MegabytesToBytes(test.CustomFileSizeMB)
|
||||
plan := make([]int64, test.CustomFileCount)
|
||||
for i := range plan {
|
||||
plan[i] = size
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
return speedTestPlanForMode(mode), nil
|
||||
}
|
||||
|
||||
func speedTestPlanForMode(mode string) []int64 {
|
||||
mode = normalizeSpeedTestMode(mode)
|
||||
switch mode {
|
||||
case StorageSpeedModeSmall:
|
||||
return repeatedSizes(24, 32*1024)
|
||||
case StorageSpeedModeBig:
|
||||
return repeatedSizes(1, 8*1024*1024)
|
||||
default:
|
||||
sizes := repeatedSizes(8, 64*1024)
|
||||
return append(sizes, repeatedSizes(1, 4*1024*1024)...)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatedSizes(count int, size int64) []int64 {
|
||||
sizes := make([]int64, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
sizes = append(sizes, size)
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
|
||||
func writeMockFile(path string, size int64, seed byte) error {
|
||||
target, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
chunk := make([]byte, 64*1024)
|
||||
for i := range chunk {
|
||||
chunk[i] = seed
|
||||
}
|
||||
remaining := size
|
||||
for remaining > 0 {
|
||||
writeSize := int64(len(chunk))
|
||||
if remaining < writeSize {
|
||||
writeSize = remaining
|
||||
}
|
||||
if _, err := target.Write(chunk[:int(writeSize)]); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining -= writeSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCustomSpeedTest(count int, sizeMB float64) error {
|
||||
if count <= 0 || count > 500 {
|
||||
return fmt.Errorf("custom speed test file count must be between 1 and 500")
|
||||
}
|
||||
if sizeMB <= 0 {
|
||||
return fmt.Errorf("custom speed test file size must be positive")
|
||||
}
|
||||
totalMB := float64(count) * sizeMB
|
||||
if totalMB > 4096 {
|
||||
return fmt.Errorf("custom speed test total size cannot exceed 4096 MB")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSpeedTestMode(mode string) string {
|
||||
switch strings.TrimSpace(mode) {
|
||||
case StorageSpeedModeSmall:
|
||||
return StorageSpeedModeSmall
|
||||
case StorageSpeedModeBig:
|
||||
return StorageSpeedModeBig
|
||||
case StorageSpeedModeCustom:
|
||||
return StorageSpeedModeCustom
|
||||
default:
|
||||
return StorageSpeedModeMixed
|
||||
}
|
||||
}
|
||||
193
backend/libs/services/storage_webdav.go
Normal file
193
backend/libs/services/storage_webdav.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type webDAVStorageBackend struct {
|
||||
cfg StorageBackendConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b webDAVStorageBackend) Type() string { return StorageBackendWebDAV }
|
||||
|
||||
func (b webDAVStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, contentType string) error {
|
||||
if err := b.mkcolParents(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := b.request(ctx, http.MethodPut, key, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("webdav put failed: %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
request, err := b.request(ctx, http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
response.Body.Close()
|
||||
return StorageObject{}, fmt.Errorf("webdav get failed: %s", response.Status)
|
||||
}
|
||||
modTime, _ := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified"))
|
||||
return StorageObject{Key: key, Size: response.ContentLength, ContentType: response.Header.Get("Content-Type"), ModTime: modTime, Body: response.Body}, nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.deletePath(ctx, key)
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
return b.deletePath(ctx, strings.TrimSuffix(prefix, "/")+"/")
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
request, err := b.request(ctx, "PROPFIND", "", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
request.Header.Set("Depth", "infinity")
|
||||
request.Header.Set("Content-Type", "application/xml")
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return 0, fmt.Errorf("webdav usage failed: %s", response.Status)
|
||||
}
|
||||
var multi webDAVMultiStatus
|
||||
if err := xml.NewDecoder(response.Body).Decode(&multi); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
for _, item := range multi.Responses {
|
||||
if item.PropStat.Prop.ResourceType.Collection != nil {
|
||||
continue
|
||||
}
|
||||
total += item.PropStat.Prop.ContentLength
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) Test(ctx context.Context) error {
|
||||
key := ".warpbox-storage-test-" + randomID(6)
|
||||
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) deletePath(ctx context.Context, key string) error {
|
||||
request, err := b.request(ctx, http.MethodDelete, key, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("webdav delete failed: %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) mkcolParents(ctx context.Context, key string) error {
|
||||
dir := path.Dir(cleanObjectKey(key))
|
||||
if dir == "." || dir == "/" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(strings.Trim(dir, "/"), "/")
|
||||
current := ""
|
||||
for _, part := range parts {
|
||||
current = path.Join(current, part)
|
||||
request, err := b.request(ctx, "MKCOL", strings.TrimSuffix(current, "/")+"/", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := b.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed && response.StatusCode != http.StatusConflict {
|
||||
return fmt.Errorf("webdav mkcol failed: %s", response.Status)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b webDAVStorageBackend) request(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
|
||||
endpoint := strings.TrimRight(b.cfg.Endpoint, "/")
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("webdav url is required")
|
||||
}
|
||||
remote := path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
|
||||
if strings.HasSuffix(key, "/") && !strings.HasSuffix(remote, "/") {
|
||||
remote += "/"
|
||||
}
|
||||
target := endpoint + "/" + strings.TrimLeft(remote, "/")
|
||||
request, err := http.NewRequestWithContext(ctx, method, target, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.cfg.Username != "" || b.cfg.Password != "" {
|
||||
request.SetBasicAuth(b.cfg.Username, b.cfg.Password)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
type webDAVMultiStatus struct {
|
||||
Responses []webDAVResponse `xml:"response"`
|
||||
}
|
||||
|
||||
type webDAVResponse struct {
|
||||
PropStat webDAVPropStat `xml:"propstat"`
|
||||
}
|
||||
|
||||
type webDAVPropStat struct {
|
||||
Prop webDAVProp `xml:"prop"`
|
||||
}
|
||||
|
||||
type webDAVProp struct {
|
||||
ContentLength int64 `xml:"getcontentlength"`
|
||||
ResourceType webDAVResourceType `xml:"resourcetype"`
|
||||
}
|
||||
|
||||
type webDAVResourceType struct {
|
||||
Collection *struct{} `xml:"collection"`
|
||||
}
|
||||
|
||||
func newWebDAVStorageBackend(cfg StorageBackendConfig) webDAVStorageBackend {
|
||||
return webDAVStorageBackend{cfg: cfg, client: http.DefaultClient}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type UploadService struct {
|
||||
|
||||
type UploadOptions struct {
|
||||
MaxDays int
|
||||
ExpiresInMinutes int
|
||||
MaxDownloads int
|
||||
Password string
|
||||
ObfuscateMetadata bool
|
||||
@@ -84,6 +85,7 @@ type UploadResult struct {
|
||||
BoxID string `json:"boxId"`
|
||||
BoxURL string `json:"boxUrl"`
|
||||
ZipURL string `json:"zipUrl"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
ManageURL string `json:"manageUrl"`
|
||||
DeleteURL string `json:"deleteUrl"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
@@ -95,6 +97,7 @@ type ResultFile struct {
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
URL string `json:"url"`
|
||||
ThumbnailURL string `json:"thumbnailUrl"`
|
||||
}
|
||||
|
||||
type AdminStats struct {
|
||||
@@ -195,8 +198,21 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
if len(files) == 0 {
|
||||
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
||||
}
|
||||
if opts.MaxDays <= 0 {
|
||||
opts.MaxDays = 7
|
||||
now := time.Now().UTC()
|
||||
var expiresAt time.Time
|
||||
switch {
|
||||
case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0:
|
||||
// "Forever" — a date far enough out that the box effectively never
|
||||
// expires. No schema change; CanDownload/cleanup keep working as-is.
|
||||
expiresAt = now.AddDate(100, 0, 0)
|
||||
case opts.ExpiresInMinutes > 0:
|
||||
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
|
||||
default:
|
||||
days := opts.MaxDays
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
}
|
||||
expiresAt = now.Add(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
|
||||
box := Box{
|
||||
@@ -205,8 +221,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
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)),
|
||||
@@ -219,15 +235,66 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
box.PasswordHash = hash
|
||||
}
|
||||
|
||||
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
|
||||
if err := s.SaveBox(box); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
|
||||
s.logger.Info("upload complete",
|
||||
"source", "user-upload",
|
||||
"severity", "user_activity",
|
||||
"code", 2001,
|
||||
"box_id", box.ID,
|
||||
"file_count", len(box.Files),
|
||||
)
|
||||
|
||||
return s.resultForBox(box, deleteToken), nil
|
||||
}
|
||||
|
||||
// AppendFiles adds files to an existing box (used to group a ShareX multi-file
|
||||
// selection into a single box). The box keeps its original expiry, password and
|
||||
// other settings; only the new files are written.
|
||||
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
||||
if len(files) == 0 {
|
||||
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
||||
}
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
if err := s.SaveBox(box); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
s.logger.Info("upload appended",
|
||||
"source", "user-upload",
|
||||
"severity", "user_activity",
|
||||
"code", 2001,
|
||||
"box_id", box.ID,
|
||||
"added", len(files),
|
||||
"file_count", len(box.Files),
|
||||
)
|
||||
return s.resultForBox(box, ""), nil
|
||||
}
|
||||
|
||||
// writeFilesToBox streams each uploaded file into the box's storage backend and
|
||||
// appends the file metadata to box.Files. The box's StorageBackendID determines
|
||||
// where files land, so it works for both new and existing boxes.
|
||||
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
|
||||
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, header := range files {
|
||||
if !opts.SkipSizeLimit {
|
||||
if err := s.ValidateSize(header.Size); err != nil {
|
||||
return UploadResult{}, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +305,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return UploadResult{}, err
|
||||
return err
|
||||
}
|
||||
|
||||
fileID := randomID(8)
|
||||
@@ -256,7 +323,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
|
||||
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
||||
file.Close()
|
||||
return UploadResult{}, err
|
||||
return err
|
||||
}
|
||||
file.Close()
|
||||
|
||||
@@ -271,20 +338,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
||||
UploadedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.SaveBox(box); err != nil {
|
||||
return UploadResult{}, err
|
||||
}
|
||||
|
||||
s.logger.Info("upload complete",
|
||||
"source", "user-upload",
|
||||
"severity", "user_activity",
|
||||
"code", 2001,
|
||||
"box_id", box.ID,
|
||||
"file_count", len(box.Files),
|
||||
)
|
||||
|
||||
return s.resultForBox(box, deleteToken), nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||
@@ -499,6 +553,28 @@ func (s *UploadService) DeleteBox(boxID string) error {
|
||||
return s.DeleteBoxWithSource(boxID, "admin")
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) {
|
||||
backendID = normalizeBackendID(backendID)
|
||||
if backendID == StorageBackendLocal {
|
||||
return 0, fmt.Errorf("local storage cannot be deleted")
|
||||
}
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted := 0
|
||||
for _, box := range boxes {
|
||||
if s.BoxStorageBackendID(box) != backendID {
|
||||
continue
|
||||
}
|
||||
if err := s.DeleteBoxWithSource(box.ID, source); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
@@ -518,7 +594,12 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
||||
return err
|
||||
}
|
||||
if box.ID != "" {
|
||||
if backend, err := s.storage.Backend(s.BoxStorageBackendID(box)); err == nil {
|
||||
backendID := s.BoxStorageBackendID(box)
|
||||
backend, err := s.storage.Backend(backendID)
|
||||
if err != nil {
|
||||
backend, err = s.storage.BackendForMaintenance(backendID)
|
||||
}
|
||||
if err == nil {
|
||||
if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -532,6 +613,80 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and
|
||||
// removes it from the box. If it was the box's last file, the whole box is
|
||||
// deleted. Returns whether the box itself was removed.
|
||||
func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
index := -1
|
||||
for i, file := range box.Files {
|
||||
if file.ID == fileID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
return false, os.ErrNotExist
|
||||
}
|
||||
file := box.Files[index]
|
||||
|
||||
backendID := s.BoxStorageBackendID(box)
|
||||
backend, err := s.storage.Backend(backendID)
|
||||
if err != nil {
|
||||
backend, err = s.storage.BackendForMaintenance(backendID)
|
||||
}
|
||||
if err == nil {
|
||||
if key := s.FileObjectKey(box, file); key != "" {
|
||||
_ = backend.Delete(context.Background(), key)
|
||||
}
|
||||
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
||||
_ = backend.Delete(context.Background(), key)
|
||||
}
|
||||
}
|
||||
|
||||
box.Files = append(box.Files[:index], box.Files[index+1:]...)
|
||||
if len(box.Files) == 0 {
|
||||
if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if err := s.SaveBox(box); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// AdminUpdateBox lets an admin change a box's expiry, download limit, and
|
||||
// optionally clear password protection.
|
||||
func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
box.ExpiresAt = expiresAt.UTC()
|
||||
}
|
||||
if maxDownloads < 0 {
|
||||
maxDownloads = 0
|
||||
}
|
||||
box.MaxDownloads = maxDownloads
|
||||
if removePassword {
|
||||
box.PasswordHash = ""
|
||||
box.PasswordSalt = ""
|
||||
box.Obfuscate = false
|
||||
}
|
||||
if err := s.SaveBox(box); err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
|
||||
for _, file := range box.Files {
|
||||
if file.ID == fileID {
|
||||
@@ -723,13 +878,22 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
||||
Name: file.Name,
|
||||
Size: helpers.FormatBytes(file.Size),
|
||||
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
|
||||
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// The box-level thumbnail points at the most recently added file, so a
|
||||
// per-file ShareX upload previews the file it just sent.
|
||||
thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
|
||||
if len(files) > 0 {
|
||||
thumbnailURL = files[len(files)-1].ThumbnailURL
|
||||
}
|
||||
|
||||
result := UploadResult{
|
||||
BoxID: box.ID,
|
||||
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
||||
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
||||
ThumbnailURL: thumbnailURL,
|
||||
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
||||
Files: files,
|
||||
}
|
||||
|
||||
@@ -172,6 +172,157 @@ func TestSFTPStorageConfigValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageUpdateRejectsProviderMutation(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Name: "SFTP",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderS3,
|
||||
Name: "Mutated",
|
||||
Endpoint: "https://s3.example.test",
|
||||
Bucket: "bucket",
|
||||
AccessKey: "access",
|
||||
SecretKey: "secret",
|
||||
UseSSL: true,
|
||||
}); err == nil {
|
||||
t.Fatalf("UpdateBackend allowed provider mutation")
|
||||
}
|
||||
stored, err := service.Storage().BackendConfig(cfg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("BackendConfig returned error: %v", err)
|
||||
}
|
||||
if stored.Provider != StorageProviderSFTP || stored.Type != StorageBackendSFTP {
|
||||
t.Fatalf("provider/type mutated despite error: %+v", stored)
|
||||
}
|
||||
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Type: StorageBackendS3,
|
||||
Name: "Mutated",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
}); err == nil {
|
||||
t.Fatalf("UpdateBackend allowed type mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageUpdatePreservesSecretsWhenBlank(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Name: "SFTP",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
PrivateKey: "private-key",
|
||||
HostKey: "host-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderSFTP,
|
||||
Name: "SFTP renamed",
|
||||
Host: "files.example.test",
|
||||
Username: "warpbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateBackend returned error: %v", err)
|
||||
}
|
||||
if updated.Password != "secret" || updated.PrivateKey != "private-key" || updated.HostKey != "host-key" {
|
||||
t.Fatalf("blank secret fields were not preserved: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContaboUpdateKeepsTLSAndPathStyleLocked(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
|
||||
Provider: StorageProviderContabo,
|
||||
Name: "Contabo",
|
||||
Endpoint: "https://eu2.contabostorage.com",
|
||||
Bucket: "My Main Bucket",
|
||||
AccessKey: "access",
|
||||
SecretKey: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
|
||||
Provider: StorageProviderContabo,
|
||||
Name: "Contabo",
|
||||
Endpoint: "https://eu2.contabostorage.com",
|
||||
Bucket: "My Main Bucket",
|
||||
AccessKey: "access",
|
||||
SecretKey: "secret",
|
||||
UseSSL: false,
|
||||
PathStyle: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateBackend returned error: %v", err)
|
||||
}
|
||||
if !updated.UseSSL || !updated.PathStyle {
|
||||
t.Fatalf("contabo TLS/path-style were not locked: %+v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageSpeedTestRequiresConnectionAndRuns(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
if _, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall); err == nil {
|
||||
t.Fatalf("StartSpeedTest allowed speed test before connection test")
|
||||
}
|
||||
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
|
||||
t.Fatalf("TestBackend local returned error: %v", err)
|
||||
}
|
||||
test, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSpeedTest returned error: %v", err)
|
||||
}
|
||||
service.Storage().RunSpeedTest(testContext(), test.ID)
|
||||
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpeedTests returned error: %v", err)
|
||||
}
|
||||
if len(tests) != 1 {
|
||||
t.Fatalf("speed tests len = %d, want 1", len(tests))
|
||||
}
|
||||
got := tests[0]
|
||||
if got.Status != StorageSpeedStatusDone || got.ProgressPercent != 100 || got.Stage != "complete" || got.BytesWritten == 0 || got.BytesRead == 0 || got.FilesWritten == 0 {
|
||||
t.Fatalf("speed test did not complete with metrics: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomStorageSpeedTestUsesRequestedFiles(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
|
||||
t.Fatalf("TestBackend local returned error: %v", err)
|
||||
}
|
||||
test, err := service.Storage().StartSpeedTestWithOptions(StorageBackendLocal, StorageSpeedTestOptions{
|
||||
Mode: StorageSpeedModeCustom,
|
||||
CustomFileCount: 3,
|
||||
CustomFileSizeMB: 0.001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartSpeedTestWithOptions returned error: %v", err)
|
||||
}
|
||||
service.Storage().RunSpeedTest(testContext(), test.ID)
|
||||
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSpeedTests returned error: %v", err)
|
||||
}
|
||||
got := tests[0]
|
||||
if got.Mode != StorageSpeedModeCustom || got.CustomFileCount != 3 || got.CustomFileSizeMB != 0.001 || got.FilesWritten != 3 || got.Status != StorageSpeedStatusDone {
|
||||
t.Fatalf("custom speed test did not use requested files: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
||||
|
||||
@@ -10,11 +10,13 @@ import (
|
||||
type Renderer struct {
|
||||
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
|
||||
@@ -58,12 +60,14 @@ func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
||||
return &Renderer{
|
||||
templates: templates,
|
||||
appName: appName,
|
||||
appVersion: appVersion,
|
||||
baseURL: baseURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
|
||||
data.AppName = r.appName
|
||||
data.AppVersion = r.appVersion
|
||||
data.BaseURL = r.baseURL
|
||||
data.CurrentYear = time.Now().Year()
|
||||
|
||||
|
||||
BIN
backend/static/WarpBoxLogo.png
Normal file
BIN
backend/static/WarpBoxLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 B |
BIN
backend/static/backgrounds/stars1.gif
Normal file
BIN
backend/static/backgrounds/stars1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
630
backend/static/css/00-base.css
Normal file
630
backend/static/css/00-base.css
Normal file
@@ -0,0 +1,630 @@
|
||||
: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="gruvbox"] {
|
||||
color-scheme: dark;
|
||||
--background: #1d2021;
|
||||
--foreground: #ebdbb2;
|
||||
--card: #282828;
|
||||
--card-foreground: #ebdbb2;
|
||||
--muted: #32302f;
|
||||
--muted-foreground: #bdae93;
|
||||
--accent: #3c3836;
|
||||
--accent-foreground: #fbf1c7;
|
||||
--border: rgba(235, 219, 178, 0.18);
|
||||
--input: rgba(235, 219, 178, 0.24);
|
||||
--primary: #d79921;
|
||||
--primary-foreground: #1d2021;
|
||||
--primary-hover: #fabd2f;
|
||||
--ring: #fe8019;
|
||||
--success: #b8bb26;
|
||||
--danger: #fb4934;
|
||||
--radius: 0.65rem;
|
||||
--shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
|
||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--header-bg: rgba(29, 32, 33, 0.86);
|
||||
--body-bg:
|
||||
radial-gradient(circle at 20% -8%, rgba(215, 153, 33, 0.2), transparent 28rem),
|
||||
radial-gradient(circle at 85% 8%, rgba(184, 187, 38, 0.12), transparent 26rem),
|
||||
linear-gradient(180deg, #1d2021 0%, #181a1b 100%);
|
||||
--surface-1: rgba(235, 219, 178, 0.06);
|
||||
--surface-1-hover: rgba(235, 219, 178, 0.11);
|
||||
--surface-2: rgba(251, 241, 199, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] {
|
||||
color-scheme: dark;
|
||||
--background: #08070d;
|
||||
--foreground: #fff36f;
|
||||
--card: #16131f;
|
||||
--card-foreground: #fff36f;
|
||||
--muted: #251d34;
|
||||
--muted-foreground: #9bfaff;
|
||||
--accent: #332246;
|
||||
--accent-foreground: #fff36f;
|
||||
--border: rgba(255, 242, 0, 0.24);
|
||||
--input: rgba(0, 240, 255, 0.34);
|
||||
--primary: #fff200;
|
||||
--primary-foreground: #08070d;
|
||||
--primary-hover: #00f0ff;
|
||||
--ring: #ff2a6d;
|
||||
--success: #00ff9f;
|
||||
--danger: #ff2a6d;
|
||||
--radius: 0.35rem;
|
||||
--shadow: 0 24px 70px rgba(255, 42, 109, 0.16), 0 0 34px rgba(0, 240, 255, 0.12);
|
||||
--font-sans: "Inter", "Rajdhani", "Orbitron", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--header-bg: rgba(8, 7, 13, 0.86);
|
||||
--body-bg:
|
||||
radial-gradient(circle at 10% -10%, rgba(255, 242, 0, 0.2), transparent 26rem),
|
||||
radial-gradient(circle at 90% 8%, rgba(0, 240, 255, 0.18), transparent 26rem),
|
||||
radial-gradient(circle at 45% 110%, rgba(255, 42, 109, 0.18), transparent 30rem),
|
||||
linear-gradient(180deg, #08070d 0%, #120b1a 100%);
|
||||
--surface-1: rgba(0, 240, 255, 0.07);
|
||||
--surface-1-hover: rgba(255, 242, 0, 0.12);
|
||||
--surface-2: rgba(255, 42, 109, 0.06);
|
||||
}
|
||||
|
||||
: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);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--body-bg);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@supports not (overflow-x: clip) {
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
canvas,
|
||||
iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
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 {
|
||||
min-width: 0;
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand > span:last-child {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
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-width: 0;
|
||||
max-width: 100%;
|
||||
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 > span,
|
||||
button > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
301
backend/static/css/10-layout.css
Normal file
301
backend/static/css/10-layout.css
Normal file
@@ -0,0 +1,301 @@
|
||||
.app-shell {
|
||||
width: min(86rem, calc(100% - 2rem));
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0;
|
||||
display: grid;
|
||||
grid-template-columns: 14rem minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
min-width: 0;
|
||||
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 {
|
||||
min-width: 0;
|
||||
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 span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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: min(15rem, 100%);
|
||||
}
|
||||
|
||||
.compact-input {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-form > *,
|
||||
.settings-section > *,
|
||||
.tabs-bar > *,
|
||||
.tab-list > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
max-width: min(15rem, calc(100vw - 2rem));
|
||||
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;
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.copy-field input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Settings sections */
|
||||
.settings-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-section-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.settings-section .checkbox-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-section label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Quota form in admin users table */
|
||||
.quota-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quota-form input {
|
||||
width: 6.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
214
backend/static/css/15-revamp.css
Normal file
214
backend/static/css/15-revamp.css
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Revamp ("Aurora glass") flourishes.
|
||||
*
|
||||
* These rules only apply to the default/revamp theme. They are scoped to
|
||||
* :root exclusions so they cover both the explicit data-theme="revamp"
|
||||
* attribute AND the no-JS default (no attribute), while never touching the
|
||||
* alternate themes. 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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Animated aurora backdrop ------------------------------------------------ */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) 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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) 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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Frosted glass cards ----------------------------------------------------- */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) 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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Outline / ghost buttons get a subtle lift on hover */
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-outline,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-ghost {
|
||||
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-outline:hover,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) :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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) input:focus,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) 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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone:hover,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-icon {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .download-item,
|
||||
:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) 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"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) main > * {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
622
backend/static/css/16-retro.css
Normal file
622
backend/static/css/16-retro.css
Normal file
@@ -0,0 +1,622 @@
|
||||
/*
|
||||
* "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. Sidebar links and tabs
|
||||
are styled as their own Win98 controls below, so they're excluded here. */
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) {
|
||||
color: #0000ee;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited {
|
||||
color: #551a8b;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):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 is a glossy raised blue button. A vertical
|
||||
gradient + strong 3D bevel keeps it clearly a button (and distinct from the
|
||||
horizontal title-bar gradient). */
|
||||
:root[data-theme="retro"] .button-primary {
|
||||
background: linear-gradient(to bottom, #2f86e0 0%, #0a3aa0 52%, #000078 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #00003a, inset 1px 1px 0 #7fc0ff, inset -2px -2px 0 #001a6a, inset 2px 2px 0 #3f9fe8;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-primary:active {
|
||||
box-shadow: inset 1px 1px 0 #00003a, inset -1px -1px 0 #7fc0ff;
|
||||
padding-top: calc(0.45rem + 1px);
|
||||
padding-left: calc(0.85rem + 1px);
|
||||
}
|
||||
|
||||
: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,
|
||||
:root[data-theme="retro"] .download-view-wide .download-card 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* App / admin shell (dashboard, account, admin pages) */
|
||||
/* These use dark revamp tokens by default, which are unreadable on the black */
|
||||
/* retro desktop. Re-skin them as Win98 chrome: a raised silver sidebar with */
|
||||
/* solid links, light page headers on the desktop, and bevelled stat cards. */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/* Sidebar = raised silver panel. */
|
||||
:root[data-theme="retro"] .app-sidebar,
|
||||
:root[data-theme="retro"] .admin-shell .app-sidebar {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .sidebar-link {
|
||||
color: #000000;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .sidebar-link:hover,
|
||||
:root[data-theme="retro"] .sidebar-link.is-active,
|
||||
:root[data-theme="retro"] .admin-shell .sidebar-link.is-active {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
border-color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .sidebar-sep {
|
||||
background: #808080;
|
||||
height: 2px;
|
||||
box-shadow: 0 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
/* Page header sits on the black desktop: light kicker, plain light title
|
||||
(not a floating title bar), light subtitle. */
|
||||
:root[data-theme="retro"] .admin-header .kicker {
|
||||
color: #ffd966;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .admin-header .muted-copy {
|
||||
color: #cfd8ff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .admin-header h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
background: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .admin-header h1::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Collection / nav tabs become small bevelled buttons. */
|
||||
:root[data-theme="retro"] .tab {
|
||||
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"] .tab:hover {
|
||||
background: #d4d0c8;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .tab.is-active {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Metric cards = sunken white stat boxes with crisp black numbers. */
|
||||
:root[data-theme="retro"] .metric-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .metric-card span {
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .metric-card strong {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* The "+ Collection" popover becomes a small floating window. */
|
||||
:root[data-theme="retro"] .new-collection-body {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* The storage inline edit form panel. */
|
||||
:root[data-theme="retro"] .storage-edit-form {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Download / box page */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/* The decorative file glyph above the title doesn't suit a Win98 window. */
|
||||
:root[data-theme="retro"] .file-emblem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The download window's content is left-aligned like a real file manager. */
|
||||
:root[data-theme="retro"] .download-view-wide .download-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Expiry shown as a sunken status field with a little clock. */
|
||||
:root[data-theme="retro"] .badge-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .badge-expiry {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.7rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .badge-expiry::before {
|
||||
content: "\23F1 ";
|
||||
}
|
||||
|
||||
/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat
|
||||
buttons that raise on hover and depress when active. */
|
||||
:root[data-theme="retro"] .view-toolbar {
|
||||
justify-content: flex-start;
|
||||
gap: 2px;
|
||||
margin-top: 1rem;
|
||||
padding: 3px;
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .button {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .button:hover {
|
||||
background: #c0c0c0;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .button.is-active {
|
||||
background: #d4d0c8;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
88
backend/static/css/17-gruvbox.css
Normal file
88
backend/static/css/17-gruvbox.css
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Gruvbox theme polish.
|
||||
*
|
||||
* Core colour tokens live in 00-base.css. This file adds the warmer, grounded
|
||||
* Gruvbox-specific surface treatment without changing layout behavior.
|
||||
*/
|
||||
|
||||
:root[data-theme="gruvbox"] .site-header {
|
||||
border-bottom-color: rgba(250, 189, 47, 0.2);
|
||||
backdrop-filter: blur(16px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(130%);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .brand-mark {
|
||||
background: linear-gradient(135deg, #d79921, #fe8019);
|
||||
color: #1d2021;
|
||||
box-shadow: 0 8px 22px rgba(254, 128, 25, 0.22);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .card,
|
||||
:root[data-theme="gruvbox"] .app-sidebar,
|
||||
:root[data-theme="gruvbox"] .storage-card,
|
||||
:root[data-theme="gruvbox"] .storage-op-card,
|
||||
:root[data-theme="gruvbox"] .metric-card,
|
||||
:root[data-theme="gruvbox"] .logs-filter-card {
|
||||
background: color-mix(in srgb, var(--card) 92%, #1d2021);
|
||||
border-color: rgba(235, 219, 178, 0.16);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .admin-shell .app-sidebar {
|
||||
border-color: rgba(250, 189, 47, 0.32);
|
||||
background: linear-gradient(180deg, rgba(215, 153, 33, 0.12), rgba(40, 40, 40, 0.94));
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .admin-shell .sidebar-link.is-active {
|
||||
border-color: rgba(250, 189, 47, 0.36);
|
||||
background: rgba(215, 153, 33, 0.14);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .admin-shell .kicker,
|
||||
:root[data-theme="gruvbox"] .kicker {
|
||||
color: #fabd2f;
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] h1 {
|
||||
color: #fbf1c7;
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .button-primary,
|
||||
:root[data-theme="gruvbox"] .button.is-active {
|
||||
border-color: rgba(250, 189, 47, 0.3);
|
||||
background: linear-gradient(135deg, #d79921, #fabd2f);
|
||||
color: #1d2021;
|
||||
box-shadow: 0 10px 24px rgba(215, 153, 33, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .button-primary:hover {
|
||||
background: linear-gradient(135deg, #fabd2f, #fe8019);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .button-outline,
|
||||
:root[data-theme="gruvbox"] .button-ghost:hover,
|
||||
:root[data-theme="gruvbox"] .button-outline:hover {
|
||||
border-color: rgba(235, 219, 178, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .badge-active,
|
||||
:root[data-theme="gruvbox"] .storage-detail-test.is-ok > span:last-child {
|
||||
color: #b8bb26;
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] .badge-disabled,
|
||||
:root[data-theme="gruvbox"] .storage-detail-test.is-err > span:last-child,
|
||||
:root[data-theme="gruvbox"] .form-error {
|
||||
color: #fb4934;
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] input:focus,
|
||||
:root[data-theme="gruvbox"] select:focus,
|
||||
:root[data-theme="gruvbox"] textarea:focus {
|
||||
border-color: #fe8019;
|
||||
box-shadow: 0 0 0 3px rgba(254, 128, 25, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="gruvbox"] ::selection {
|
||||
background: #d79921;
|
||||
color: #1d2021;
|
||||
}
|
||||
196
backend/static/css/18-cyberpunk.css
Normal file
196
backend/static/css/18-cyberpunk.css
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* CyberPunk theme polish.
|
||||
*
|
||||
* Inspired by neon Cyberpunk 2077 UI treatments: warning yellow surfaces,
|
||||
* cyan/magenta light, hard edges, scanlines, and high-contrast panels.
|
||||
*/
|
||||
|
||||
:root[data-theme="cyberpunk"] body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 100% 3px, 3rem 100%;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(115deg, transparent 0 18%, rgba(255, 242, 0, 0.06) 18% 19%, transparent 19% 100%),
|
||||
linear-gradient(245deg, transparent 0 76%, rgba(255, 42, 109, 0.08) 76% 77%, transparent 77% 100%);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .site-header {
|
||||
border-bottom-color: rgba(255, 242, 0, 0.32);
|
||||
box-shadow: 0 0 22px rgba(0, 240, 255, 0.12);
|
||||
backdrop-filter: blur(12px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .brand {
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .brand-mark {
|
||||
background: #fff200;
|
||||
color: #08070d;
|
||||
box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.45), 0 0 18px rgba(255, 242, 0, 0.42);
|
||||
clip-path: polygon(0 0, 100% 0, 100% 72%, 78% 100%, 0 100%);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] h1 {
|
||||
color: #fff200;
|
||||
text-shadow: 2px 0 0 rgba(255, 42, 109, 0.58), -2px 0 0 rgba(0, 240, 255, 0.46);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .card,
|
||||
:root[data-theme="cyberpunk"] .app-sidebar,
|
||||
:root[data-theme="cyberpunk"] .storage-card,
|
||||
:root[data-theme="cyberpunk"] .storage-op-card,
|
||||
:root[data-theme="cyberpunk"] .metric-card,
|
||||
:root[data-theme="cyberpunk"] .logs-filter-card,
|
||||
:root[data-theme="cyberpunk"] .advanced-options {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(22, 19, 31, 0.96), rgba(13, 10, 20, 0.96)),
|
||||
linear-gradient(90deg, rgba(255, 242, 0, 0.16), rgba(0, 240, 255, 0.08));
|
||||
border-color: rgba(255, 242, 0, 0.28);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .card::before,
|
||||
:root[data-theme="cyberpunk"] .storage-card::before,
|
||||
:root[data-theme="cyberpunk"] .metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-top: 1px solid rgba(0, 240, 255, 0.4);
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .admin-shell .app-sidebar {
|
||||
border-color: rgba(255, 42, 109, 0.38);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 42, 109, 0.16), rgba(8, 7, 13, 0.94)),
|
||||
#16131f;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .sidebar-link:hover,
|
||||
:root[data-theme="cyberpunk"] .sidebar-link.is-active,
|
||||
:root[data-theme="cyberpunk"] .admin-shell .sidebar-link.is-active {
|
||||
border-color: rgba(255, 242, 0, 0.42);
|
||||
background: linear-gradient(90deg, rgba(255, 242, 0, 0.2), rgba(0, 240, 255, 0.08));
|
||||
color: #fff200;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .kicker,
|
||||
:root[data-theme="cyberpunk"] .admin-shell .kicker {
|
||||
color: #00f0ff;
|
||||
text-shadow: 0 0 12px rgba(0, 240, 255, 0.36);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .button-primary,
|
||||
:root[data-theme="cyberpunk"] .button.is-active {
|
||||
border-color: #fff200;
|
||||
background: #fff200;
|
||||
color: #08070d;
|
||||
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.7), 0 0 18px rgba(255, 242, 0, 0.3);
|
||||
clip-path: polygon(0 0, calc(100% - 0.7rem) 0, 100% 0.7rem, 100% 100%, 0.7rem 100%, 0 calc(100% - 0.7rem));
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .button-primary:hover,
|
||||
:root[data-theme="cyberpunk"] .button.is-active:hover {
|
||||
background: #00f0ff;
|
||||
border-color: #00f0ff;
|
||||
color: #08070d;
|
||||
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.78), 0 0 22px rgba(0, 240, 255, 0.42);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .button-outline,
|
||||
:root[data-theme="cyberpunk"] .button-ghost {
|
||||
border-color: rgba(0, 240, 255, 0.28);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .button-outline:hover,
|
||||
:root[data-theme="cyberpunk"] .button-ghost:hover {
|
||||
border-color: rgba(255, 242, 0, 0.46);
|
||||
background: rgba(255, 242, 0, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] input,
|
||||
:root[data-theme="cyberpunk"] select,
|
||||
:root[data-theme="cyberpunk"] textarea {
|
||||
background: rgba(8, 7, 13, 0.92);
|
||||
border-color: rgba(0, 240, 255, 0.34);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] input:focus,
|
||||
:root[data-theme="cyberpunk"] select:focus,
|
||||
:root[data-theme="cyberpunk"] textarea:focus {
|
||||
border-color: #fff200;
|
||||
box-shadow: 0 0 0 3px rgba(255, 242, 0, 0.16), 0 0 22px rgba(0, 240, 255, 0.18);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .badge {
|
||||
border: 1px solid rgba(0, 240, 255, 0.22);
|
||||
background: rgba(0, 240, 255, 0.08);
|
||||
color: #9bfaff;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .badge-active,
|
||||
:root[data-theme="cyberpunk"] .storage-detail-test.is-ok > span:last-child {
|
||||
color: #00ff9f;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .badge-disabled,
|
||||
:root[data-theme="cyberpunk"] .storage-detail-test.is-err > span:last-child,
|
||||
:root[data-theme="cyberpunk"] .form-error {
|
||||
color: #ff2a6d;
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .drop-zone {
|
||||
border-color: rgba(255, 242, 0, 0.34);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 242, 0, 0.08), transparent 38%),
|
||||
rgba(8, 7, 13, 0.76);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] .drop-zone:hover,
|
||||
:root[data-theme="cyberpunk"] .drop-zone.is-dragging {
|
||||
border-color: #00f0ff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(0, 240, 255, 0.14), transparent 42%),
|
||||
rgba(8, 7, 13, 0.82);
|
||||
}
|
||||
|
||||
:root[data-theme="cyberpunk"] ::selection {
|
||||
background: #ff2a6d;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root[data-theme="cyberpunk"] .brand-mark,
|
||||
:root[data-theme="cyberpunk"] h1 {
|
||||
animation: cyberpunk-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cyberpunk-pulse {
|
||||
0%, 100% {
|
||||
filter: none;
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 0.45rem rgba(0, 240, 255, 0.28));
|
||||
}
|
||||
}
|
||||
363
backend/static/css/20-upload.css
Normal file
363
backend/static/css/20-upload.css
Normal file
@@ -0,0 +1,363 @@
|
||||
.upload-view {
|
||||
width: min(64rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Two-column upload layout: drop-zone window on the left, options on the
|
||||
right. Collapses to a single column on narrow screens (see 90-responsive). */
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.upload-main,
|
||||
.upload-options {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.options-title {
|
||||
margin: 0 0 1.1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Stack the option fields vertically in the narrower right-hand window. */
|
||||
.upload-options .option-grid {
|
||||
grid-template-columns: 1fr;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Summary + upload button sit at the bottom of the options window. */
|
||||
.upload-options .form-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.upload-options .form-footer .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
margin: 0 0 2.5rem 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.85rem;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
|
||||
.upload-subtitle {
|
||||
margin: 0.35rem 0 1.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
min-height: 19rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 2rem;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-1);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.is-dragging {
|
||||
border-color: var(--primary);
|
||||
background: var(--surface-1-hover);
|
||||
}
|
||||
|
||||
.drop-zone input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.drop-icon svg {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.drop-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.drop-copy,
|
||||
.drop-meta {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drop-meta {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.advanced-options {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-2);
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.advanced-options summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.form-footer,
|
||||
.result-header {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-footer p,
|
||||
#result-meta {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
min-height: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
padding: 0.45rem 0.85rem;
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.button-outline {
|
||||
border-color: var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.button-outline:hover,
|
||||
.button-ghost:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
border-color: rgba(248, 113, 113, 0.28);
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.button-danger:hover {
|
||||
background: rgba(127, 29, 29, 0.55);
|
||||
}
|
||||
|
||||
.button-wide {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 0.4rem;
|
||||
margin-top: 0.55rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.progress span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.upload-result {
|
||||
border-color: rgba(244, 244, 245, 0.24);
|
||||
background: rgba(244, 244, 245, 0.06);
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.result-title svg {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.manage-link {
|
||||
margin: 0.9rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manage-link a {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-list,
|
||||
.download-list {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.upload-queue {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.result-item,
|
||||
.download-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item > span,
|
||||
.download-item > span {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-item strong,
|
||||
.download-item strong,
|
||||
.result-item code,
|
||||
.download-item code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: min(10rem, 32vw);
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.file-progress-percent {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
height: 0.35rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result-item small,
|
||||
.download-item small,
|
||||
.result-item code,
|
||||
.download-item code {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
274
backend/static/css/30-download.css
Normal file
274
backend/static/css/30-download.css
Normal file
@@ -0,0 +1,274 @@
|
||||
.download-view {
|
||||
width: min(38rem, calc(100% - 2rem));
|
||||
min-height: calc(100vh - 7.25rem);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.download-view-wide {
|
||||
width: min(58rem, calc(100% - 2rem));
|
||||
}
|
||||
|
||||
.download-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-emblem {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.file-emblem svg {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.badge-row {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.download-item {
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.view-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.button.is-active {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.file-browser {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
flex: 0 0 4.75rem;
|
||||
width: 4.75rem;
|
||||
aspect-ratio: 16 / 10;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.thumb-link img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-main {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 1;
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-action [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs {
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .thumb-link {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-browser.is-thumbs .file-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.file-browser.images-only .file-card:not([data-kind="image"]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
width: 10.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: color-mix(in srgb, var(--card) 96%, #000);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46);
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-menu button {
|
||||
width: 100%;
|
||||
min-height: 2.05rem;
|
||||
justify-content: flex-start;
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
padding: 0.42rem 0.5rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.context-menu button:hover,
|
||||
.context-menu button:focus-visible,
|
||||
.context-menu button.is-copied {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.context-menu-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1rem 0.1rem 0.2rem 0.45rem;
|
||||
}
|
||||
|
||||
.context-menu-top small {
|
||||
color: color-mix(in srgb, var(--muted-foreground) 74%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-menu-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.context-menu-icons button {
|
||||
width: 1.9rem;
|
||||
min-height: 1.9rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.context-menu hr {
|
||||
height: 1px;
|
||||
margin: 0.35rem 0.2rem;
|
||||
border: 0;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unlock-form {
|
||||
margin: 1rem auto 0;
|
||||
display: grid;
|
||||
max-width: 22rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.manage-details {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.manage-details div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.45rem 0;
|
||||
}
|
||||
|
||||
.manage-details dt,
|
||||
.manage-details dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manage-details dt {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.manage-details dd {
|
||||
color: var(--foreground);
|
||||
font-size: 0.84rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.preview-stage img,
|
||||
.preview-stage video {
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-stage audio {
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
}
|
||||
120
backend/static/css/40-docs.css
Normal file
120
backend/static/css/40-docs.css
Normal file
@@ -0,0 +1,120 @@
|
||||
.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 h3 {
|
||||
margin: 1.35rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Highlights where the API token goes in the ShareX config snippet. */
|
||||
.sxcu-highlight {
|
||||
background: #fde047;
|
||||
color: #1a1a1a;
|
||||
font-weight: 700;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
451
backend/static/css/50-admin.css
Normal file
451
backend/static/css/50-admin.css
Normal file
@@ -0,0 +1,451 @@
|
||||
.admin-header,
|
||||
.table-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-header > *,
|
||||
.table-header > *,
|
||||
.admin-grid-two > *,
|
||||
.logs-filter-card > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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,
|
||||
.metric-grid-4 {
|
||||
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 {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 1rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
min-width: 46rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.sort-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 650;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sort-link:hover,
|
||||
.sort-link.is-sorted {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sort-arrow {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-summary {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-bar .pagination {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.per-page-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.per-page-control select {
|
||||
width: auto;
|
||||
min-width: 4.5rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.button.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Overview charts */
|
||||
.admin-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.chart-card .muted-copy {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
height: 180px;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-chart-col {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-chart-track {
|
||||
width: 100%;
|
||||
max-width: 2.2rem;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar-chart-bar {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: linear-gradient(180deg, var(--primary, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
|
||||
}
|
||||
|
||||
.bar-chart-value {
|
||||
color: var(--foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.bar-chart-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.66rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-bars {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-bar span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.stat-bar span strong {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.stat-bar-track {
|
||||
margin-top: 0.35rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--primary, #8b5cf6);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.metric-grid-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logs-filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.logs-filter-card label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-filter-card label span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.logs-table code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compact-form textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.logs-filter-card {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.logs-filter-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* 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;
|
||||
flex-wrap: wrap;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
486
backend/static/css/60-storage.css
Normal file
486
backend/static/css/60-storage.css
Normal file
@@ -0,0 +1,486 @@
|
||||
/* ── 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;
|
||||
min-width: 0;
|
||||
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;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
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;
|
||||
}
|
||||
|
||||
.storage-card-actions form {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* View-mode summary */
|
||||
.storage-card-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
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;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.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 > *,
|
||||
.storage-ops-grid > *,
|
||||
.storage-result-row,
|
||||
.storage-result-row summary > *,
|
||||
.storage-result-detail > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-card-fields label {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.storage-card-fields label span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.storage-card-fields textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.storage-card-fields .checkbox-field {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.storage-card-edit-bar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.storage-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.storage-type-option {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0.3rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
||||
.storage-type-option:hover {
|
||||
border-color: rgba(125, 211, 252, 0.35);
|
||||
background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3));
|
||||
}
|
||||
|
||||
.storage-type-option svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.storage-type-option strong {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.storage-type-option span {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.storage-ops-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.storage-op-card {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
align-content: start;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
}
|
||||
|
||||
.storage-op-card strong {
|
||||
color: var(--foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.storage-op-card span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.storage-op-card .button {
|
||||
justify-self: start;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.storage-form-note {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.storage-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.storage-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.storage-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.storage-modal-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(30rem, 100%);
|
||||
max-height: min(42rem, 90vh);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow, 0 1rem 2.5rem rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
.storage-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.storage-speed-form,
|
||||
.storage-results-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.storage-results-page {
|
||||
padding: 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.storage-tests-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.storage-speed-option {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 90%, var(--muted));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.storage-speed-option span {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.storage-speed-option small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.storage-custom-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.65rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.storage-custom-fields[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.storage-custom-fields label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.storage-result-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 92%, transparent);
|
||||
}
|
||||
|
||||
.storage-result-row summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.storage-test-progress {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem 0.65rem;
|
||||
}
|
||||
|
||||
.storage-test-progress-bar {
|
||||
height: 0.45rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.storage-test-progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: color-mix(in srgb, var(--primary) 70%, #86efac);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.storage-test-progress small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.storage-result-status {
|
||||
justify-self: end;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.storage-result-status.is-done { color: #86efac; }
|
||||
.storage-result-status.is-failed { color: #fca5a5; }
|
||||
.storage-result-status.is-running { color: #fde68a; }
|
||||
|
||||
.storage-result-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.storage-result-detail span {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.76rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-result-detail strong {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.storage-result-error {
|
||||
grid-column: 1 / -1;
|
||||
color: #fca5a5 !important;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.storage-ops-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-result-row summary,
|
||||
.storage-result-detail,
|
||||
.storage-custom-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-result-status {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
59
backend/static/css/70-tokens.css
Normal file
59
backend/static/css/70-tokens.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* ── Access tokens ───────────────────────────────────────────────────────── */
|
||||
|
||||
.token-create-form {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-create-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
flex: 1;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.token-reveal {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid rgba(134, 239, 172, 0.3);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(134, 239, 172, 0.08);
|
||||
}
|
||||
|
||||
.token-reveal-title {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 650;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.token-reveal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.token-reveal-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.125rem);
|
||||
background: var(--background);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy {
|
||||
margin: 0.6rem 0 0;
|
||||
}
|
||||
|
||||
.token-reveal .muted-copy code {
|
||||
word-break: break-all;
|
||||
}
|
||||
245
backend/static/css/90-responsive.css
Normal file
245
backend/static/css/90-responsive.css
Normal file
@@ -0,0 +1,245 @@
|
||||
@media (max-width: 720px) {
|
||||
.nav {
|
||||
width: min(72rem, calc(100% - 1rem));
|
||||
min-height: auto;
|
||||
padding: 0.55rem 0;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nav-links .button {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding-inline: 0.55rem;
|
||||
}
|
||||
|
||||
.upload-view,
|
||||
.download-view {
|
||||
width: min(100%, calc(100% - 1rem));
|
||||
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;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.sidebar-logout .button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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%;
|
||||
max-width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.inline-controls {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.inline-controls label,
|
||||
.inline-controls input,
|
||||
.inline-controls select,
|
||||
.compact-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.copy-field,
|
||||
.token-reveal-row,
|
||||
.storage-card-edit-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.copy-field .button,
|
||||
.token-reveal-row .button,
|
||||
.storage-card-edit-bar .button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.storage-card-header,
|
||||
.storage-card-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.storage-card-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storage-card-actions,
|
||||
.storage-card-actions form,
|
||||
.storage-card-actions .button,
|
||||
.storage-card-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.storage-card-summary {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.storage-detail {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-card-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.app-shell {
|
||||
width: min(100%, calc(100% - 1rem));
|
||||
padding: 1rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.user-edit-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.storage-type-grid,
|
||||
.storage-ops-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.result-item,
|
||||
.download-item {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-actions,
|
||||
.file-browser.is-thumbs .file-actions {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-progress-side {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
width: min(100%, calc(100% - 1rem));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.sidebar-nav {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.badge-row .badge {
|
||||
flex: 1 1 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-links .button {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf
Normal file
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf
Normal file
Binary file not shown.
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono.ttf
Normal file
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono.ttf
Normal file
Binary file not shown.
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf
Normal file
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf
Normal file
Binary file not shown.
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans.ttf
Normal file
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans.ttf
Normal file
Binary file not shown.
62
backend/static/js/00-utils.js
Normal file
62
backend/static/js/00-utils.js
Normal file
@@ -0,0 +1,62 @@
|
||||
(function () {
|
||||
window.Warpbox = window.Warpbox || {};
|
||||
|
||||
window.Warpbox.openInNewTab = function openInNewTab(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
window.Warpbox.writeClipboard = async function writeClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
};
|
||||
|
||||
window.Warpbox.copyText = async function copyText(text, button, copiedLabel) {
|
||||
if (!text || !button) {
|
||||
return;
|
||||
}
|
||||
await window.Warpbox.writeClipboard(text);
|
||||
const previous = button.textContent;
|
||||
button.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
button.textContent = previous;
|
||||
}, 1400);
|
||||
};
|
||||
|
||||
window.Warpbox.formatDate = function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
window.Warpbox.formatBytes = function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
};
|
||||
})();
|
||||
53
backend/static/js/05-theme.js
Normal file
53
backend/static/js/05-theme.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Theme init + toggle.
|
||||
*
|
||||
* Loaded in <head> WITHOUT defer so the first block runs before paint and sets
|
||||
* the theme attribute, avoiding a flash of the wrong theme. The choice lives in
|
||||
* localStorage (no cookie, no server round-trip) and applies site-wide.
|
||||
*
|
||||
* CSP note: this is an external /static file, so it is allowed under
|
||||
* script-src 'self'. We only toggle an attribute / class — never inject inline
|
||||
* <style> — which keeps style-src 'self' happy.
|
||||
*/
|
||||
(function () {
|
||||
var STORAGE_KEY = "warpbox-theme";
|
||||
var THEMES = ["revamp", "classic", "retro", "gruvbox", "cyberpunk"];
|
||||
|
||||
function stored() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
if (THEMES.indexOf(theme) === -1) {
|
||||
theme = "revamp";
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
return theme;
|
||||
}
|
||||
|
||||
// Runs immediately (before paint).
|
||||
var current = apply(stored() || "revamp");
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var select = document.querySelector("[data-theme-select]");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reflect the active theme in the dropdown.
|
||||
select.value = current;
|
||||
|
||||
select.addEventListener("change", function () {
|
||||
current = apply(select.value);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, current);
|
||||
} catch (e) {
|
||||
/* ignore persistence failures (private mode, etc.) */
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
191
backend/static/js/10-file-browser.js
Normal file
191
backend/static/js/10-file-browser.js
Normal file
@@ -0,0 +1,191 @@
|
||||
(function () {
|
||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||
const previewImages = document.querySelector("[data-preview-images]");
|
||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||
|
||||
let ctrlCopyMode = false;
|
||||
let contextFile = null;
|
||||
const contextMenuCloseDistance = 80;
|
||||
|
||||
if (fileBrowser) {
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.getAttribute("data-view-button");
|
||||
fileBrowser.classList.toggle("is-list", view === "list");
|
||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
if (previewImages) {
|
||||
previewImages.addEventListener("click", () => {
|
||||
fileBrowser.classList.toggle("images-only");
|
||||
previewImages.classList.toggle("is-active");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileBrowser && fileContextMenu) {
|
||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||
const card = event.target.closest("[data-file-context]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
contextFile = {
|
||||
previewURL: card.dataset.previewUrl,
|
||||
viewURL: card.dataset.viewUrl,
|
||||
downloadURL: card.dataset.downloadUrl,
|
||||
fileName: card.dataset.fileName,
|
||||
};
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
fileContextMenu.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-context-action]");
|
||||
if (!button || !contextFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
||||
if (shouldHide !== false) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!fileContextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", hideContextMenu);
|
||||
window.addEventListener("scroll", hideContextMenu, true);
|
||||
}
|
||||
|
||||
if (previewActions.length > 0) {
|
||||
previewActions.forEach((button) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
if (!event.ctrlKey && !ctrlCopyMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await copyPreviewLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => setPreviewCopyMode(false));
|
||||
}
|
||||
|
||||
async function copyPreviewLink(button) {
|
||||
await window.Warpbox.writeClipboard(button.href);
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setPreviewCopyMode(enabled) {
|
||||
ctrlCopyMode = enabled;
|
||||
previewActions.forEach((button) => {
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
||||
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
||||
if (label) {
|
||||
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}
|
||||
if (viewIcon) {
|
||||
viewIcon.hidden = enabled;
|
||||
}
|
||||
if (copyIcon) {
|
||||
copyIcon.hidden = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runContextAction(action, file) {
|
||||
if (action === "preview") {
|
||||
window.Warpbox.openInNewTab(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "view") {
|
||||
window.Warpbox.openInNewTab(file.viewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-preview") {
|
||||
await window.Warpbox.writeClipboard(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-download") {
|
||||
await window.Warpbox.writeClipboard(file.downloadURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "download") {
|
||||
window.Warpbox.openInNewTab(file.downloadURL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
fileContextMenu.hidden = false;
|
||||
fileContextMenu.style.left = "0px";
|
||||
fileContextMenu.style.top = "0px";
|
||||
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
||||
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
||||
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
if (!fileContextMenu || fileContextMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
fileContextMenu.hidden = true;
|
||||
contextFile = null;
|
||||
}
|
||||
|
||||
function isPointerNearContextMenu(x, y) {
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
return x >= rect.left - contextMenuCloseDistance &&
|
||||
x <= rect.right + contextMenuCloseDistance &&
|
||||
y >= rect.top - contextMenuCloseDistance &&
|
||||
y <= rect.bottom + contextMenuCloseDistance;
|
||||
}
|
||||
})();
|
||||
127
backend/static/js/20-storage-admin.js
Normal file
127
backend/static/js/20-storage-admin.js
Normal file
@@ -0,0 +1,127 @@
|
||||
(function () {
|
||||
document.querySelectorAll("[data-storage-delete-warning]").forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
const name = button.getAttribute("data-storage-delete-warning") || "this storage backend";
|
||||
const confirmed = window.confirm(
|
||||
`Delete ${name}?\n\nAll boxes stored on this location will also be deleted. Any global defaults or user storage overrides pointing at it will be reset back to inherited local storage.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-storage-speed-open]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const modal = document.querySelector("[data-storage-speed-modal]");
|
||||
if (modal) {
|
||||
modal.hidden = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-storage-modal-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const modal = button.closest(".storage-modal");
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll(".storage-modal").forEach((modal) => {
|
||||
modal.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-speed-form").forEach((form) => {
|
||||
const customFields = form.querySelector("[data-storage-custom-fields]");
|
||||
function syncCustomFields() {
|
||||
if (!customFields) {
|
||||
return;
|
||||
}
|
||||
const customSelected = form.querySelector('input[name="mode"]:checked')?.value === "custom";
|
||||
customFields.hidden = !customSelected;
|
||||
customFields.querySelectorAll("input").forEach((input) => {
|
||||
input.disabled = !customSelected;
|
||||
});
|
||||
}
|
||||
form.querySelectorAll('input[name="mode"]').forEach((input) => {
|
||||
input.addEventListener("change", syncCustomFields);
|
||||
});
|
||||
syncCustomFields();
|
||||
});
|
||||
|
||||
const testList = document.querySelector("[data-storage-tests-page]");
|
||||
if (!testList) {
|
||||
return;
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function renderTest(test) {
|
||||
const progress = Math.max(0, Math.min(100, Number(test.progress || 0)));
|
||||
const error = test.error
|
||||
? `<span class="storage-result-error"><strong>Error</strong>${escapeHTML(test.error)}</span>`
|
||||
: "";
|
||||
return `
|
||||
<details class="storage-result-row" data-storage-test-id="${escapeHTML(test.id)}">
|
||||
<summary>
|
||||
<span>${escapeHTML(test.startedLabel)}</span>
|
||||
<span>${escapeHTML(test.customLabel || test.modeLabel)}</span>
|
||||
<span class="storage-result-status is-${escapeHTML(test.status)}">${escapeHTML(test.status)}</span>
|
||||
</summary>
|
||||
<div class="storage-test-progress" aria-label="Test progress">
|
||||
<div class="storage-test-progress-bar"><span style="width: ${progress}%"></span></div>
|
||||
<small>${progress}%${test.stage ? " · " + escapeHTML(test.stage) : ""}</small>
|
||||
</div>
|
||||
<div class="storage-result-detail">
|
||||
<span><strong>Finished</strong>${escapeHTML(test.finishedLabel)}</span>
|
||||
<span><strong>Files</strong>${escapeHTML(test.files)}</span>
|
||||
<span><strong>Size</strong>${escapeHTML(test.sizeLabel)}</span>
|
||||
<span><strong>Write</strong>${escapeHTML(test.writeSpeed)}</span>
|
||||
<span><strong>Read</strong>${escapeHTML(test.readSpeed)}</span>
|
||||
${error}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function refreshTests() {
|
||||
const url = testList.getAttribute("data-storage-tests-url");
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch(url, { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const openIDs = new Set(Array.from(testList.querySelectorAll("details[open]")).map((row) => row.dataset.storageTestId));
|
||||
const tests = payload.tests || [];
|
||||
if (tests.length === 0) {
|
||||
return;
|
||||
}
|
||||
testList.innerHTML = tests.map(renderTest).join("");
|
||||
testList.querySelectorAll("details").forEach((row) => {
|
||||
if (openIDs.has(row.dataset.storageTestId)) {
|
||||
row.open = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
refreshTests().catch(() => {});
|
||||
}, 1200);
|
||||
})();
|
||||
14
backend/static/js/30-token-copy.js
Normal file
14
backend/static/js/30-token-copy.js
Normal file
@@ -0,0 +1,14 @@
|
||||
(function () {
|
||||
const tokenCopyBtn = document.querySelector("[data-token-copy]");
|
||||
if (!tokenCopyBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokenCopyBtn.addEventListener("click", () => {
|
||||
const valueEl = document.querySelector("[data-token-value]");
|
||||
if (!valueEl) {
|
||||
return;
|
||||
}
|
||||
window.Warpbox.copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied");
|
||||
});
|
||||
})();
|
||||
43
backend/static/js/35-pagination.js
Normal file
43
backend/static/js/35-pagination.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// Per-page selector: remembers the chosen page size in localStorage and keeps
|
||||
// the URL's `per` query param in sync. CSP-safe (external file, no inline JS).
|
||||
(function () {
|
||||
const select = document.querySelector("[data-per-page]");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = "warpbox-perpage-" + select.dataset.perPage;
|
||||
const url = new URL(window.location.href);
|
||||
const current = url.searchParams.get("per");
|
||||
let stored = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(key);
|
||||
} catch (err) {
|
||||
stored = null;
|
||||
}
|
||||
|
||||
// No explicit choice in the URL but a remembered preference exists: apply it.
|
||||
if (!current && stored && stored !== select.value) {
|
||||
const valid = Array.prototype.some.call(select.options, function (opt) {
|
||||
return opt.value === stored;
|
||||
});
|
||||
if (valid) {
|
||||
url.searchParams.set("per", stored);
|
||||
url.searchParams.delete("page");
|
||||
window.location.replace(url.toString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
select.addEventListener("change", function () {
|
||||
try {
|
||||
window.localStorage.setItem(key, select.value);
|
||||
} catch (err) {
|
||||
/* ignore storage failures (private mode, etc.) */
|
||||
}
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.set("per", select.value);
|
||||
next.searchParams.delete("page");
|
||||
window.location.assign(next.toString());
|
||||
});
|
||||
})();
|
||||
274
backend/static/js/40-upload.js
Normal file
274
backend/static/js/40-upload.js
Normal file
@@ -0,0 +1,274 @@
|
||||
(function () {
|
||||
const form = document.querySelector("#upload-form");
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const progress = document.querySelector("#upload-progress");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const totalProgressBar = document.querySelector("#total-progress-bar");
|
||||
const copyURL = document.querySelector("#copy-url");
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember the last-chosen expiry across uploads (per browser).
|
||||
const expirySelect = form.querySelector("[data-expiry-select]");
|
||||
if (expirySelect) {
|
||||
const EXPIRY_KEY = "warpbox-expiry";
|
||||
let saved = null;
|
||||
try {
|
||||
saved = localStorage.getItem(EXPIRY_KEY);
|
||||
} catch (e) {
|
||||
saved = null;
|
||||
}
|
||||
if (saved && expirySelect.querySelector('option[value="' + saved + '"]')) {
|
||||
expirySelect.value = saved;
|
||||
}
|
||||
expirySelect.addEventListener("change", () => {
|
||||
try {
|
||||
localStorage.setItem(EXPIRY_KEY, expirySelect.value);
|
||||
} catch (e) {
|
||||
/* ignore persistence failures */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateSelectedState(event.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => updateSelectedState(fileInput.files));
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = new FormData(form);
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderQueue(selectedFiles, "queued");
|
||||
setLoading(true, submit);
|
||||
|
||||
try {
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
});
|
||||
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
selectedFiles = Array.from(files || []);
|
||||
const count = selectedFiles.length || 0;
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (title) {
|
||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
if (fileSummary) {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
uploadQueue.hidden = true;
|
||||
uploadQueue.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(isLoading, submit) {
|
||||
if (progress) {
|
||||
progress.hidden = !isLoading;
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = isLoading;
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
if (!result || !resultList || !resultMeta || !openBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestBoxURL = payload.boxUrl;
|
||||
result.hidden = false;
|
||||
openBox.href = payload.boxUrl;
|
||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
|
||||
if (manageLink) {
|
||||
const anchor = manageLink.querySelector("a");
|
||||
manageLink.hidden = !payload.manageUrl;
|
||||
if (anchor && payload.manageUrl) {
|
||||
anchor.href = payload.manageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
resultList.replaceChildren();
|
||||
payload.files.forEach((file) => {
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadWithProgress(url, formData, files) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", url);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
updateStatus("Uploading...");
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
updateStatus(`${percent}%`);
|
||||
setTotalProgress(percent);
|
||||
setFileProgress(files, percent);
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(request.responseText || "{}");
|
||||
} catch (error) {
|
||||
reject(new Error("Upload response could not be read"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(payload.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
setTotalProgress(100);
|
||||
setFileProgress(files, 100);
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: window.Warpbox.formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createFileRow(file) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item upload-file-row";
|
||||
row.dataset.fileName = file.name;
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.className = "file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const meta = document.createElement("code");
|
||||
meta.textContent = file.meta;
|
||||
body.append(name, meta);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "file-progress-side";
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "file-progress-percent";
|
||||
percent.textContent = `${file.progress}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "progress file-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||
bar.append(fill);
|
||||
side.append(percent, bar);
|
||||
|
||||
row.append(body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setTotalProgress(percent) {
|
||||
if (totalProgressBar) {
|
||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setFileProgress(files, totalPercent) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const count = files.length || 1;
|
||||
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
||||
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
||||
const percent = row.querySelector(".file-progress-percent");
|
||||
const fill = row.querySelector(".file-progress span");
|
||||
if (percent) {
|
||||
percent.textContent = `${progress}%`;
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.transform = `scaleX(${progress / 100})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,626 +0,0 @@
|
||||
(function () {
|
||||
const form = document.querySelector("#upload-form");
|
||||
const dropZone = document.querySelector(".drop-zone");
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const progress = document.querySelector("#upload-progress");
|
||||
const uploadStatus = document.querySelector("#upload-status");
|
||||
const result = document.querySelector("#upload-result");
|
||||
const resultMeta = document.querySelector("#result-meta");
|
||||
const resultList = document.querySelector("#result-list");
|
||||
const uploadQueue = document.querySelector("#upload-queue");
|
||||
const totalProgressBar = document.querySelector("#total-progress-bar");
|
||||
const copyURL = document.querySelector("#copy-url");
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||
const previewImages = document.querySelector("[data-preview-images]");
|
||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
||||
let ctrlCopyMode = false;
|
||||
let contextFile = null;
|
||||
const contextMenuCloseDistance = 80;
|
||||
|
||||
if (fileBrowser) {
|
||||
viewButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const view = button.getAttribute("data-view-button");
|
||||
fileBrowser.classList.toggle("is-list", view === "list");
|
||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
});
|
||||
});
|
||||
|
||||
if (previewImages) {
|
||||
previewImages.addEventListener("click", () => {
|
||||
fileBrowser.classList.toggle("images-only");
|
||||
previewImages.classList.toggle("is-active");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileBrowser && fileContextMenu) {
|
||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||
const card = event.target.closest("[data-file-context]");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
contextFile = {
|
||||
previewURL: card.dataset.previewUrl,
|
||||
viewURL: card.dataset.viewUrl,
|
||||
downloadURL: card.dataset.downloadUrl,
|
||||
fileName: card.dataset.fileName,
|
||||
};
|
||||
showContextMenu(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
fileContextMenu.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("[data-context-action]");
|
||||
if (!button || !contextFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
||||
if (shouldHide !== false) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!fileContextMenu.contains(event.target)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
hideContextMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", (event) => {
|
||||
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
||||
return;
|
||||
}
|
||||
hideContextMenu();
|
||||
});
|
||||
|
||||
window.addEventListener("resize", hideContextMenu);
|
||||
window.addEventListener("scroll", hideContextMenu, true);
|
||||
}
|
||||
|
||||
if (previewActions.length > 0) {
|
||||
previewActions.forEach((button) => {
|
||||
button.addEventListener("click", async (event) => {
|
||||
if (!event.ctrlKey && !ctrlCopyMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await copyPreviewLink(button);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(true);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Control") {
|
||||
setPreviewCopyMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
setPreviewCopyMode(false);
|
||||
});
|
||||
}
|
||||
|
||||
function syncStorageProvider(select) {
|
||||
const formScope = select.closest("form");
|
||||
if (!formScope) {
|
||||
return;
|
||||
}
|
||||
const provider = select.value;
|
||||
const isContabo = provider === "contabo";
|
||||
formScope.querySelectorAll("[data-provider-fields]").forEach((group) => {
|
||||
const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/);
|
||||
const active = providers.includes(provider);
|
||||
group.hidden = !active;
|
||||
group.querySelectorAll("input, select, textarea").forEach((input) => {
|
||||
input.disabled = !active;
|
||||
});
|
||||
});
|
||||
const tls = formScope.querySelector('input[name="use_ssl"]');
|
||||
const pathStyle = formScope.querySelector('input[name="path_style"]');
|
||||
if (tls) {
|
||||
tls.checked = isContabo || tls.checked;
|
||||
tls.disabled = isContabo;
|
||||
}
|
||||
if (pathStyle) {
|
||||
pathStyle.checked = isContabo || pathStyle.checked;
|
||||
pathStyle.disabled = isContabo;
|
||||
}
|
||||
}
|
||||
|
||||
if (storageProviderSelects.length > 0) {
|
||||
storageProviderSelects.forEach((select) => {
|
||||
select.addEventListener("change", () => syncStorageProvider(select));
|
||||
syncStorageProvider(select);
|
||||
});
|
||||
}
|
||||
|
||||
/* Storage card edit / cancel toggles */
|
||||
document.querySelectorAll(".storage-edit-trigger").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".storage-card");
|
||||
if (!card) return;
|
||||
card.classList.add("is-editing");
|
||||
const providerSelect = card.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".storage-cancel-trigger").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const card = btn.closest(".storage-card");
|
||||
if (!card) return;
|
||||
const form = card.querySelector("form");
|
||||
if (form) form.reset();
|
||||
card.classList.remove("is-editing");
|
||||
});
|
||||
});
|
||||
|
||||
/* Add storage: type picker */
|
||||
const storageAddTrigger = document.querySelector(".storage-add-trigger");
|
||||
const storageTypePicker = document.querySelector(".storage-type-picker");
|
||||
const storageNewCard = document.querySelector(".storage-new-card");
|
||||
|
||||
const providerLabels = {
|
||||
s3: "S3 bucket",
|
||||
contabo: "Contabo Object Storage",
|
||||
sftp: "SFTP",
|
||||
smb: "Samba",
|
||||
webdav: "WebDAV",
|
||||
};
|
||||
|
||||
const providerIconSVGs = {
|
||||
s3: storageNewCard && storageNewCard.querySelector(".storage-new-icon") ? storageNewCard.querySelector(".storage-new-icon").innerHTML : "",
|
||||
contabo: "",
|
||||
sftp: "",
|
||||
smb: "",
|
||||
webdav: "",
|
||||
};
|
||||
|
||||
if (storageAddTrigger && storageTypePicker) {
|
||||
storageAddTrigger.addEventListener("click", () => {
|
||||
storageTypePicker.hidden = !storageTypePicker.hidden;
|
||||
if (storageNewCard && !storageTypePicker.hidden) {
|
||||
storageNewCard.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
storageTypePicker.querySelectorAll(".storage-type-option").forEach((opt) => {
|
||||
opt.addEventListener("click", () => {
|
||||
const provider = opt.dataset.provider;
|
||||
if (!storageNewCard) return;
|
||||
|
||||
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
||||
if (providerSelect) {
|
||||
providerSelect.value = provider;
|
||||
syncStorageProvider(providerSelect);
|
||||
}
|
||||
|
||||
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
||||
if (typeBadge) typeBadge.textContent = providerLabels[provider] || provider;
|
||||
|
||||
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
||||
const optIcon = opt.querySelector("svg");
|
||||
if (iconEl && optIcon) {
|
||||
iconEl.innerHTML = optIcon.outerHTML;
|
||||
}
|
||||
|
||||
storageTypePicker.hidden = true;
|
||||
storageNewCard.hidden = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (storageNewCard) {
|
||||
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
storageNewCard.hidden = true;
|
||||
if (storageTypePicker) storageTypePicker.hidden = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
let latestBoxURL = "";
|
||||
let selectedFiles = [];
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove("is-dragging");
|
||||
});
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
fileInput.files = event.dataTransfer.files;
|
||||
updateSelectedState(event.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
updateSelectedState(fileInput.files);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files || fileInput.files.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = new FormData(form);
|
||||
selectedFiles = Array.from(fileInput.files);
|
||||
renderQueue(selectedFiles, "queued");
|
||||
setLoading(true, submit);
|
||||
|
||||
try {
|
||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
form.reset();
|
||||
updateSelectedState([]);
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
});
|
||||
|
||||
if (copyURL) {
|
||||
copyURL.addEventListener("click", () => {
|
||||
copyText(latestBoxURL, copyURL, "Copied");
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedState(files) {
|
||||
selectedFiles = Array.from(files || []);
|
||||
const count = selectedFiles.length || 0;
|
||||
const title = dropZone.querySelector(".drop-title");
|
||||
if (title) {
|
||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||
}
|
||||
if (fileSummary) {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
uploadQueue.hidden = true;
|
||||
uploadQueue.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
function setLoading(isLoading, submit) {
|
||||
if (progress) {
|
||||
progress.hidden = !isLoading;
|
||||
}
|
||||
if (submit) {
|
||||
submit.disabled = isLoading;
|
||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||
}
|
||||
updateStatus(isLoading ? "Transferring files..." : "");
|
||||
setTotalProgress(isLoading ? 0 : 100);
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
if (uploadStatus) {
|
||||
uploadStatus.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
if (!result || !resultList || !resultMeta || !openBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestBoxURL = payload.boxUrl;
|
||||
result.hidden = false;
|
||||
openBox.href = payload.boxUrl;
|
||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
||||
if (manageLink) {
|
||||
const anchor = manageLink.querySelector("a");
|
||||
manageLink.hidden = !payload.manageUrl;
|
||||
if (anchor && payload.manageUrl) {
|
||||
anchor.href = payload.manageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
resultList.replaceChildren();
|
||||
payload.files.forEach((file) => {
|
||||
resultList.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: `${file.size} · ${file.url}`,
|
||||
progress: 100,
|
||||
status: "complete",
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function uploadWithProgress(url, formData, files) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("POST", url);
|
||||
request.setRequestHeader("Accept", "application/json");
|
||||
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (!event.lengthComputable) {
|
||||
updateStatus("Uploading...");
|
||||
return;
|
||||
}
|
||||
const percent = Math.round((event.loaded / event.total) * 100);
|
||||
updateStatus(`${percent}%`);
|
||||
setTotalProgress(percent);
|
||||
setFileProgress(files, percent);
|
||||
});
|
||||
|
||||
request.addEventListener("load", () => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = JSON.parse(request.responseText || "{}");
|
||||
} catch (error) {
|
||||
reject(new Error("Upload response could not be read"));
|
||||
return;
|
||||
}
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
reject(new Error(payload.error || "Upload failed"));
|
||||
return;
|
||||
}
|
||||
setTotalProgress(100);
|
||||
setFileProgress(files, 100);
|
||||
resolve(payload);
|
||||
});
|
||||
|
||||
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
||||
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function createFileRow(file) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "result-item upload-file-row";
|
||||
row.dataset.fileName = file.name;
|
||||
|
||||
const body = document.createElement("span");
|
||||
const name = document.createElement("strong");
|
||||
name.className = "file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
const meta = document.createElement("code");
|
||||
meta.textContent = file.meta;
|
||||
body.append(name, meta);
|
||||
|
||||
const side = document.createElement("div");
|
||||
side.className = "file-progress-side";
|
||||
const percent = document.createElement("span");
|
||||
percent.className = "file-progress-percent";
|
||||
percent.textContent = `${file.progress}%`;
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "progress file-progress";
|
||||
const fill = document.createElement("span");
|
||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||
bar.append(fill);
|
||||
side.append(percent, bar);
|
||||
|
||||
row.append(body, side);
|
||||
return row;
|
||||
}
|
||||
|
||||
function setTotalProgress(percent) {
|
||||
if (totalProgressBar) {
|
||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||
}
|
||||
}
|
||||
|
||||
function setFileProgress(files, totalPercent) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const count = files.length || 1;
|
||||
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
||||
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
||||
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
||||
const percent = row.querySelector(".file-progress-percent");
|
||||
const fill = row.querySelector(".file-progress span");
|
||||
if (percent) {
|
||||
percent.textContent = `${progress}%`;
|
||||
}
|
||||
if (fill) {
|
||||
fill.style.transform = `scaleX(${progress / 100})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function copyText(text, button, copiedLabel) {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
await writeClipboard(text);
|
||||
const previous = button.textContent;
|
||||
button.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
button.textContent = previous;
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
async function copyPreviewLink(button) {
|
||||
await writeClipboard(button.href);
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = "Copied";
|
||||
setTimeout(() => {
|
||||
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setPreviewCopyMode(enabled) {
|
||||
ctrlCopyMode = enabled;
|
||||
previewActions.forEach((button) => {
|
||||
const label = button.querySelector("[data-preview-label]");
|
||||
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
||||
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
||||
if (label) {
|
||||
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
||||
}
|
||||
if (viewIcon) {
|
||||
viewIcon.hidden = enabled;
|
||||
}
|
||||
if (copyIcon) {
|
||||
copyIcon.hidden = !enabled;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runContextAction(action, file) {
|
||||
if (action === "preview") {
|
||||
openInNewTab(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "view") {
|
||||
openInNewTab(file.viewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-preview") {
|
||||
await writeClipboard(file.previewURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "copy-download") {
|
||||
await writeClipboard(file.downloadURL);
|
||||
return true;
|
||||
}
|
||||
if (action === "download") {
|
||||
openInNewTab(file.downloadURL);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function showContextMenu(x, y) {
|
||||
fileContextMenu.hidden = false;
|
||||
fileContextMenu.style.left = "0px";
|
||||
fileContextMenu.style.top = "0px";
|
||||
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
||||
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
||||
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
||||
}
|
||||
|
||||
function hideContextMenu() {
|
||||
if (!fileContextMenu || fileContextMenu.hidden) {
|
||||
return;
|
||||
}
|
||||
fileContextMenu.hidden = true;
|
||||
contextFile = null;
|
||||
}
|
||||
|
||||
function isPointerNearContextMenu(x, y) {
|
||||
const rect = fileContextMenu.getBoundingClientRect();
|
||||
return x >= rect.left - contextMenuCloseDistance &&
|
||||
x <= rect.right + contextMenuCloseDistance &&
|
||||
y >= rect.top - contextMenuCloseDistance &&
|
||||
y <= rect.bottom + contextMenuCloseDistance;
|
||||
}
|
||||
|
||||
function openInNewTab(url) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
async function writeClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.append(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
textarea.remove();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes / 1024;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
})();
|
||||
@@ -15,8 +15,26 @@
|
||||
{{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?version={{.AppVersion}}"></script>
|
||||
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/50-admin.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/60-storage.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
|
||||
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
@@ -45,7 +63,17 @@
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
|
||||
<span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}}</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>
|
||||
<option value="gruvbox">Gruvbox</option>
|
||||
<option value="cyberpunk">CyberPunk</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -42,6 +42,61 @@
|
||||
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-panel">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Access tokens</h2>
|
||||
<p>Personal tokens act as your account for the API and CLI. They never expire until you delete them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
|
||||
{{if .Data.NewToken}}
|
||||
<div class="token-reveal">
|
||||
<p class="token-reveal-title">Copy your new token now — it won't be shown again.</p>
|
||||
<div class="token-reveal-row">
|
||||
<code class="token-reveal-value" data-token-value>{{.Data.NewToken}}</code>
|
||||
<button class="button button-outline button-sm" type="button" data-token-copy>Copy</button>
|
||||
</div>
|
||||
<p class="muted-copy">Use it as a bearer token: <code>Authorization: Bearer {{.Data.NewToken}}</code></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="token-create-form" action="/account/tokens" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>Token name</span><input name="name" placeholder="e.g. CLI on laptop" maxlength="80" required></label>
|
||||
<button class="button button-primary button-sm" type="submit">Generate token</button>
|
||||
</form>
|
||||
|
||||
{{if .Data.Tokens}}
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Tokens}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.LastUsedAt}}</td>
|
||||
<td class="table-actions">
|
||||
<form action="/account/tokens/{{.ID}}/delete" method="post" onsubmit="return confirm('Delete this token? Any client using it will stop working.');">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted-copy">No tokens yet. Generate one above to use the API or CLI.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
@@ -56,6 +58,55 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="admin-charts">
|
||||
<div class="card chart-card">
|
||||
<div class="card-content">
|
||||
<h2>Uploads per day</h2>
|
||||
<p class="muted-copy">New boxes created over the last 14 days.</p>
|
||||
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
|
||||
{{range .Data.Overview.UploadDays}}
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||
<span class="bar-chart-value">{{.Value}}</span>
|
||||
<span class="bar-chart-track"><span class="bar-chart-bar" style="height: {{.Height}}%"></span></span>
|
||||
<span class="bar-chart-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card">
|
||||
<div class="card-content">
|
||||
<h2>Box status</h2>
|
||||
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
|
||||
<div class="stat-bars">
|
||||
{{range .Data.Overview.StatusBars}}
|
||||
<div class="stat-bar">
|
||||
<span>{{.Label}} <strong>{{.Value}}</strong></span>
|
||||
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card">
|
||||
<div class="card-content">
|
||||
<h2>Storage added per day</h2>
|
||||
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
|
||||
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
|
||||
{{range .Data.Overview.StorageDays}}
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||
<span class="bar-chart-value">{{.Value}}</span>
|
||||
<span class="bar-chart-track"><span class="bar-chart-bar" style="height: {{.Height}}%"></span></span>
|
||||
<span class="bar-chart-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
|
||||
153
backend/templates/pages/admin_bans.html
Normal file
153
backend/templates/pages/admin_bans.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{{define "admin_bans.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-bans-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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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">Operator console</p>
|
||||
<h1 id="admin-bans-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Manual IP/CIDR bans and optional automatic abuse protection.</p>
|
||||
</div>
|
||||
<a class="button button-outline" href="/admin/logs">Open logs</a>
|
||||
</div>
|
||||
|
||||
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
|
||||
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
|
||||
|
||||
<div class="metric-grid metric-grid-4">
|
||||
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
|
||||
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
|
||||
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
|
||||
<article class="metric-card"><span>Auto-ban</span><strong>{{if .Data.Bans.Settings.AutoBanEnabled}}Enabled{{else}}Off{{end}}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="admin-grid-two">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<h2>Manual ban</h2>
|
||||
<form class="settings-form compact-form" action="/admin/bans" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>IP or CIDR</span><input name="target" placeholder="203.0.113.10 or 203.0.113.0/24" required></label>
|
||||
<label><span>Reason</span><input name="reason" placeholder="Repeated abuse" required></label>
|
||||
<label><span>Ban until</span><input type="datetime-local" name="expires_at" required></label>
|
||||
<button class="button button-danger" type="submit">Ban target</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<h2>Auto-ban settings</h2>
|
||||
<form class="settings-form compact-form" action="/admin/bans/settings" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="auto_ban_enabled" {{if .Data.Bans.Settings.AutoBanEnabled}}checked{{end}}>
|
||||
<span>Enable automatic bans</span>
|
||||
</label>
|
||||
<label><span>Auto-ban duration (hours)</span><input type="number" min="1" name="auto_ban_duration_hours" value="{{.Data.Bans.Settings.AutoBanDurationHours}}" required></label>
|
||||
<label><span>Abuse window (hours)</span><input type="number" min="1" name="abuse_window_hours" value="{{.Data.Bans.Settings.AbuseWindowHours}}" required></label>
|
||||
<label><span>Malicious path threshold</span><input type="number" min="1" name="malicious_path_threshold" value="{{.Data.Bans.Settings.MaliciousPathThreshold}}" required></label>
|
||||
<label><span>Admin login failures</span><input type="number" min="1" name="admin_login_failure_threshold" value="{{.Data.Bans.Settings.AdminLoginFailureThreshold}}" required></label>
|
||||
<label><span>User login failures</span><input type="number" min="1" name="user_login_failure_threshold" value="{{.Data.Bans.Settings.UserLoginFailureThreshold}}" required></label>
|
||||
<button class="button button-primary" type="submit">Save auto-ban settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Ban records</h2>
|
||||
<p>Active records block requests before the normal route handler runs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="logs-filter-card" method="get" action="/admin/bans">
|
||||
<label><span>Status</span>
|
||||
<select name="status">
|
||||
<option value="">All</option>
|
||||
<option value="active" {{if eq .Data.Bans.Status "active"}}selected{{end}}>Active</option>
|
||||
<option value="expired" {{if eq .Data.Bans.Status "expired"}}selected{{end}}>Expired</option>
|
||||
<option value="unbanned" {{if eq .Data.Bans.Status "unbanned"}}selected{{end}}>Unbanned</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Search</span><input name="q" value="{{.Data.Bans.Query}}" placeholder="IP, CIDR, reason"></label>
|
||||
<button class="button button-outline" type="submit">Filter</button>
|
||||
</form>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Reason</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
<th>Last match</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Bans.Bans}}
|
||||
<tr>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td><span class="badge">{{.Status}}</span></td>
|
||||
<td>{{.ExpiresAt}}</td>
|
||||
<td>{{.LastMatched}}</td>
|
||||
<td>
|
||||
{{if eq .Status "active"}}
|
||||
<form action="/admin/bans/{{.ID}}/unban" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-outline" type="submit">Unban</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="muted-copy">No action</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">No bans match this filter.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<h2>Malicious path rules</h2>
|
||||
<p class="muted-copy">One case-insensitive substring per line. These rules only create bans when auto-ban is enabled.</p>
|
||||
<form class="settings-form compact-form" action="/admin/bans/rules" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>Patterns</span><textarea name="patterns" rows="10" spellcheck="false">{{.Data.Bans.RulePatterns}}</textarea></label>
|
||||
<button class="button button-primary" type="submit">Save rules</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
131
backend/templates/pages/admin_box_edit.html
Normal file
131
backend/templates/pages/admin_box_edit.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{{define "admin_box_edit.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-box-edit-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 is-active" 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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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">Operator console · <a href="/admin/files">Files</a></p>
|
||||
<h1 id="admin-box-edit-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Box <code>{{.Data.Box.ID}}</code> · {{.Data.Box.Owner}}</p>
|
||||
</div>
|
||||
<a class="button button-outline" href="/admin/boxes/{{.Data.Box.ID}}/view">Open box</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="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Box settings</h2>
|
||||
<p>Change expiration, download limit, and protection.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="manage-details">
|
||||
<div><dt>Created</dt><dd>{{.Data.Box.CreatedAt}}</dd></div>
|
||||
<div><dt>Files</dt><dd>{{.Data.Box.FileCount}}</dd></div>
|
||||
<div><dt>Total size</dt><dd>{{.Data.Box.TotalSize}}</dd></div>
|
||||
<div><dt>Downloads</dt><dd>{{.Data.Box.DownloadCount}}{{if .Data.Box.MaxDownloads}} / {{.Data.Box.MaxDownloads}}{{end}}</dd></div>
|
||||
<div><dt>Expires</dt><dd>{{.Data.Box.ExpiresLabel}}</dd></div>
|
||||
<div><dt>Storage backend</dt><dd>{{.Data.Box.BackendID}}</dd></div>
|
||||
<div><dt>Protected</dt><dd>{{if .Data.Box.Protected}}Yes{{else}}No{{end}}</dd></div>
|
||||
</dl>
|
||||
|
||||
<form class="settings-form settings-form-narrow" action="/admin/boxes/{{.Data.Box.ID}}/edit" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>
|
||||
<span>Expires at (UTC)</span>
|
||||
<input type="datetime-local" name="expires_at" value="{{.Data.Box.ExpiresInput}}">
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="never_expires" {{if .Data.Box.NeverExpires}}checked{{end}}>
|
||||
<span>Never expires (overrides the date above)</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Max downloads (0 = unlimited)</span>
|
||||
<input type="number" min="0" name="max_downloads" value="{{.Data.Box.MaxDownloads}}">
|
||||
</label>
|
||||
{{if .Data.Box.Protected}}
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="remove_password">
|
||||
<span>Remove password protection</span>
|
||||
</label>
|
||||
{{end}}
|
||||
<button class="button button-primary" type="submit">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Files</h2>
|
||||
<p>Remove individual files from this box. Removing the last file deletes the box.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list">
|
||||
{{range .Data.Files}}
|
||||
<article class="download-item">
|
||||
{{if .HasPreview}}<a class="thumb-link" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer"><img src="{{.ThumbnailURL}}" alt="" loading="lazy"></a>{{end}}
|
||||
<a class="file-main" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer">
|
||||
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
|
||||
<small>{{.Size}} · {{.ContentType}}</small>
|
||||
</a>
|
||||
<div class="file-actions">
|
||||
<a class="button button-outline button-sm" href="{{.DownloadURL}}" download="{{.Name}}">Download</a>
|
||||
<form action="/admin/boxes/{{$.Data.Box.ID}}/files/{{.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{else}}
|
||||
<p class="muted-copy">This box has no files.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Danger zone</h2>
|
||||
<p>Permanently delete this box and all of its files.</p>
|
||||
</div>
|
||||
<form action="/admin/boxes/{{.Data.Box.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button button-danger" type="submit">Delete box</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
114
backend/templates/pages/admin_files.html
Normal file
114
backend/templates/pages/admin_files.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{{define "admin_files.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-files-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 is-active" 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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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">Operator console</p>
|
||||
<h1 id="admin-files-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">{{.Data.Total}} box{{if ne .Data.Total 1}}es{{end}} total.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>All uploads</h2>
|
||||
<p>Search, sort, and manage every box.</p>
|
||||
</div>
|
||||
<form class="inline-controls" method="get" action="/admin/files">
|
||||
<input type="hidden" name="sort" value="{{.Data.Sort}}">
|
||||
<input type="hidden" name="dir" value="{{.Data.Dir}}">
|
||||
<input type="hidden" name="per" value="{{.Data.PerPage}}">
|
||||
<label>
|
||||
<span class="sr-only">Search</span>
|
||||
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
|
||||
</label>
|
||||
<button class="button button-primary button-sm" type="submit">Search</button>
|
||||
{{if .Data.Query}}<a class="button button-outline button-sm" href="/admin/files">Clear</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{range .Data.Columns}}
|
||||
<th><a class="sort-link {{if .Sorted}}is-sorted{{end}}" href="{{.Href}}">{{.Label}}{{if .Sorted}}<span class="sort-arrow" aria-hidden="true">{{if .Ascending}}▲{{else}}▼{{end}}</span>{{end}}</a></th>
|
||||
{{end}}
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Boxes}}
|
||||
<tr>
|
||||
<td><a href="/admin/boxes/{{.ID}}/edit"><code>{{.ID}}</code></a></td>
|
||||
<td>{{.Owner}}</td>
|
||||
<td>{{.FileCount}}</td>
|
||||
<td>{{.TotalSizeLabel}}</td>
|
||||
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.ExpiresAt}}</td>
|
||||
<td>
|
||||
{{if .Expired}}<span class="badge">expired</span>{{else}}<span class="badge">active</span>{{end}}
|
||||
{{if .Protected}}<span class="badge">protected</span>{{end}}
|
||||
</td>
|
||||
<td class="table-actions">
|
||||
<a class="button button-primary button-sm" href="/admin/boxes/{{.ID}}/edit">Edit</a>
|
||||
<a class="button button-outline button-sm" href="/admin/boxes/{{.ID}}/view">View</a>
|
||||
<form action="/admin/boxes/{{.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="9">No boxes match.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||
</nav>
|
||||
<label class="per-page-control">
|
||||
<span>Per page</span>
|
||||
<select data-per-page="files" aria-label="Items per page">
|
||||
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="pagination-summary">Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
121
backend/templates/pages/admin_logs.html
Normal file
121
backend/templates/pages/admin_logs.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{{define "admin_logs.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-logs-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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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">Operator console</p>
|
||||
<h1 id="admin-logs-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Browse JSON log lines from the local log files.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="logs-filter-card" method="get" action="/admin/logs">
|
||||
<label><span>Date</span>
|
||||
<select name="date">
|
||||
<option value="all" {{if eq .Data.Logs.Date "all"}}selected{{end}}>All dates</option>
|
||||
{{range .Data.Logs.Dates}}<option value="{{.}}" {{if eq $.Data.Logs.Date .}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Severity</span>
|
||||
<select name="severity">
|
||||
<option value="" {{if eq .Data.Logs.Severity ""}}selected{{end}}>All</option>
|
||||
<option value="dev" {{if eq .Data.Logs.Severity "dev"}}selected{{end}}>dev</option>
|
||||
<option value="user_activity" {{if eq .Data.Logs.Severity "user_activity"}}selected{{end}}>user_activity</option>
|
||||
<option value="warn" {{if eq .Data.Logs.Severity "warn"}}selected{{end}}>warn</option>
|
||||
<option value="error" {{if eq .Data.Logs.Severity "error"}}selected{{end}}>error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Source</span><input name="source" value="{{.Data.Logs.Source}}" placeholder="auth, admin, upload"></label>
|
||||
<label><span>Search</span><input name="q" value="{{.Data.Logs.Query}}" placeholder="message, IP, path, user id"></label>
|
||||
<label><span>Sort</span>
|
||||
<select name="sort">
|
||||
<option value="desc" {{if eq .Data.Logs.Sort "desc"}}selected{{end}}>Newest first</option>
|
||||
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
|
||||
<button class="button button-primary" type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Log entries</h2>
|
||||
<p>{{.Data.Logs.Total}} entries match these filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Severity</th>
|
||||
<th>Source</th>
|
||||
<th>Code</th>
|
||||
<th>Message</th>
|
||||
<th>Actor/IP</th>
|
||||
<th>Route</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Logs.Entries}}
|
||||
<tr>
|
||||
<td><span class="log-time">{{.Date}} {{.Time}}</span></td>
|
||||
<td><span class="badge">{{.Severity}}</span></td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{.Code}}</td>
|
||||
<td>
|
||||
<strong>{{.Message}}</strong>
|
||||
{{if .Details}}<details><summary>Details</summary><code>{{.Details}}</code></details>{{end}}
|
||||
</td>
|
||||
<td>{{if .UserID}}<code>{{.UserID}}</code>{{end}}{{if .IP}}<br><span>{{.IP}}</span>{{end}}</td>
|
||||
<td>{{if .Method}}{{.Method}}{{end}} {{if .Path}}<code>{{.Path}}</code>{{end}}{{if .Status}}<br><span>Status {{.Status}}</span>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">No log entries match those filters.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{{if .Data.Logs.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.Logs.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||
{{range .Data.Logs.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||
{{if .Data.Logs.HasNext}}<a class="button button-outline button-sm" href="{{.Data.Logs.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||
</nav>
|
||||
<label class="per-page-control">
|
||||
<span>Per page</span>
|
||||
<select data-per-page="logs" aria-label="Items per page">
|
||||
{{range .Data.Logs.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.Logs.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}–{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
@@ -34,7 +36,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>
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<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>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
@@ -28,15 +30,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">
|
||||
@@ -53,33 +76,31 @@
|
||||
{{if eq .Config.ID "local"}}<span class="badge">Required</span>
|
||||
{{else if .Config.Enabled}}<span class="badge badge-active">Enabled</span>
|
||||
{{else}}<span class="badge badge-disabled">Disabled</span>{{end}}
|
||||
{{if .InUseReason}}<span class="badge" title="{{.InUseReason}}">In use</span>{{end}}
|
||||
{{if .UsageLabel}}<span class="storage-card-usage">{{.UsageLabel}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{{if ne .Config.ID "local"}}
|
||||
<button class="button button-outline button-sm storage-edit-trigger" type="button">Edit</button>
|
||||
{{if .Config.Enabled}}
|
||||
<form action="/admin/storage/{{.Config.ID}}/disable" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-outline button-sm" type="submit" {{if .InUse}}disabled title="Backend is in use"{{end}}>Disable</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if ne .Config.ID "local"}}
|
||||
<a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/edit">Edit</a>
|
||||
<form action="/admin/storage/{{.Config.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit" {{if .InUse}}disabled title="Backend is in use"{{end}}>Delete</button>
|
||||
<button class="button button-danger button-sm" type="submit" data-storage-delete-warning="{{.Config.Name}}">Delete</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</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 +128,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>
|
||||
|
||||
119
backend/templates/pages/admin_storage_form.html
Normal file
119
backend/templates/pages/admin_storage_form.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{{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>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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}}
|
||||
52
backend/templates/pages/admin_storage_new.html
Normal file
52
backend/templates/pages/admin_storage_new.html
Normal file
@@ -0,0 +1,52 @@
|
||||
{{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>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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}}
|
||||
146
backend/templates/pages/admin_storage_tests.html
Normal file
146
backend/templates/pages/admin_storage_tests.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{{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>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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}}
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link is-active" 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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</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>
|
||||
@@ -38,7 +40,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 +52,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 <code>-1</code> for unlimited in any limit field — upload size, daily caps, storage quota, max expiration (the box can then last forever), daily boxes, active boxes, and short-window requests. Storage quota <code>0</code> also means unlimited.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post">
|
||||
@@ -87,10 +89,10 @@
|
||||
<h3 class="settings-section-title">Upload limits</h3>
|
||||
<label><span>Max upload size (MB)</span><input name="max_upload_mb" value="{{.Data.UserEdit.MaxUploadMB}}" placeholder="inherit"></label>
|
||||
<label><span>Daily upload cap (MB)</span><input name="daily_upload_mb" value="{{.Data.UserEdit.DailyUploadMB}}" placeholder="inherit"></label>
|
||||
<label><span>Max expiration (days)</span><input type="number" min="1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
|
||||
<label><span>Daily boxes</span><input type="number" min="1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Active boxes</span><input type="number" min="1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Short-window requests</span><input type="number" min="1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
|
||||
<label><span>Max expiration (days)</span><input type="number" min="-1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
|
||||
<label><span>Daily boxes</span><input type="number" min="-1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Active boxes</span><input type="number" min="-1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Short-window requests</span><input type="number" min="-1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
|
||||
</div>
|
||||
|
||||
<button class="button button-primary" type="submit">Save user</button>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link is-active" 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" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -36,11 +36,12 @@
|
||||
<article class="card docs-card">
|
||||
<div class="card-content">
|
||||
<h2>JSON response</h2>
|
||||
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private.</p>
|
||||
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private. On error the body is <code>{ "error": "message" }</code> with a non-2xx status (e.g. rate limited or over a limit).</p>
|
||||
<pre><code>{
|
||||
"boxId": "abc123",
|
||||
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
|
||||
"zipUrl": "{{.Data.BaseURL}}/d/abc123/zip",
|
||||
"thumbnailUrl": "{{.Data.BaseURL}}/d/abc123/thumb/file123",
|
||||
"manageUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token",
|
||||
"deleteUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token/delete",
|
||||
"expiresAt": "2026-06-05T12:00:00Z",
|
||||
@@ -49,7 +50,8 @@
|
||||
"id": "file123",
|
||||
"name": "report.pdf",
|
||||
"size": "2.4 MiB",
|
||||
"url": "{{.Data.BaseURL}}/d/abc123/f/file123"
|
||||
"url": "{{.Data.BaseURL}}/d/abc123/f/file123",
|
||||
"thumbnailUrl": "{{.Data.BaseURL}}/d/abc123/thumb/file123"
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
@@ -59,22 +61,44 @@
|
||||
<article class="card docs-card">
|
||||
<div class="card-content">
|
||||
<h2>ShareX setup</h2>
|
||||
<p>Import the uploader, then add your API key to upload as your account — with your account's size, daily, and retention limits — instead of as an anonymous guest.</p>
|
||||
|
||||
<h3>1 · Import the uploader</h3>
|
||||
<ol class="docs-steps">
|
||||
<li>Download the instance config: <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>/api/v1/sharex/warpbox-anonymous.sxcu</code></a>.</li>
|
||||
<li>Or open the tracked template at <code>{{.Data.ShareXExamplePath}}</code> and change <code>RequestURL</code> to <code>{{.Data.ShareXExampleURL}}</code>.</li>
|
||||
<li>Keep <code>FileFormName</code> as <code>{{.Data.ShareXFileFieldName}}</code>.</li>
|
||||
<li>Import the <code>.sxcu</code> file into ShareX as a custom uploader.</li>
|
||||
<li>Upload a file. ShareX will use <code>boxUrl</code> as the public URL and <code>manageUrl</code> as the deletion URL.</li>
|
||||
<li>Download <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>warpbox-anonymous.sxcu</code></a>.</li>
|
||||
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
|
||||
</ol>
|
||||
|
||||
<h3>2 · Add your API key (upload as your account)</h3>
|
||||
<ol class="docs-steps">
|
||||
<li>Create a personal access token under <a href="/account/settings">Account → Access tokens</a> and copy it.</li>
|
||||
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
|
||||
<li>Add a header — Name <code>Authorization</code>, Value <code>Bearer <your token></code>.</li>
|
||||
</ol>
|
||||
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
|
||||
|
||||
<pre><code>{
|
||||
"Version": "1.0.0",
|
||||
"Name": "Warpbox (my account)",
|
||||
"DestinationType": "ImageUploader, FileUploader, TextUploader",
|
||||
"RequestMethod": "POST",
|
||||
"RequestURL": "{{.Data.ShareXExampleURL}}",
|
||||
"Headers": { "Accept": "application/json" },
|
||||
"Headers": {
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer <span class="sxcu-highlight">YOUR_API_TOKEN</span>",
|
||||
"X-Warpbox-Batch": "sharex"
|
||||
},
|
||||
"Body": "MultipartFormData",
|
||||
"FileFormName": "{{.Data.ShareXFileFieldName}}",
|
||||
"URL": "$json:boxUrl$",
|
||||
"DeletionURL": "$json:manageUrl$"
|
||||
"URL": "{json:boxUrl}",
|
||||
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||
"DeletionURL": "{json:deleteUrl}",
|
||||
"ErrorMessage": "{json:error}"
|
||||
}</code></pre>
|
||||
|
||||
<h3>Grouping multiple files into one box</h3>
|
||||
<p>Grouping is <strong>opt-in via the <code>X-Warpbox-Batch</code> request header</strong> — without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a multi-file ShareX selection produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
|
||||
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -85,6 +109,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>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div class="file-emblem" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
|
||||
</div>
|
||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Download files{{end}}</h1>
|
||||
<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>
|
||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
||||
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
||||
|
||||
{{if .Data.Locked}}
|
||||
<form class="unlock-form" action="/d/{{.Data.Box.ID}}/unlock" method="post">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{{if .Data.Files}}
|
||||
<div class="badge-row">
|
||||
<span class="badge">Expires {{.Data.ExpiresLabel}}</span>
|
||||
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
|
||||
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,16 +4,22 @@
|
||||
<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">
|
||||
<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>
|
||||
@@ -24,11 +30,20 @@
|
||||
<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,22 +73,13 @@
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="upload-result card" id="upload-result" hidden aria-live="polite">
|
||||
|
||||
22
docker-compose-prod.yml
Normal file
22
docker-compose-prod.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
app:
|
||||
image: tea.chunkbyte.com/kato/warpbox-dev:latest
|
||||
container_name: warpbox-dev
|
||||
env_file:
|
||||
- .prod.env
|
||||
environment:
|
||||
WARPBOX_ADDR: ":8080"
|
||||
WARPBOX_DATA_DIR: /data
|
||||
WARPBOX_STATIC_DIR: /app/static
|
||||
WARPBOX_TEMPLATE_DIR: /app/templates
|
||||
volumes:
|
||||
- ./data:/data:Z
|
||||
ports:
|
||||
- "6070:8080"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
@@ -5,10 +5,13 @@
|
||||
"RequestMethod": "POST",
|
||||
"RequestURL": "https://warpbox.dev/api/v1/upload",
|
||||
"Headers": {
|
||||
"Accept": "application/json"
|
||||
"Accept": "application/json",
|
||||
"X-Warpbox-Batch": "sharex"
|
||||
},
|
||||
"Body": "MultipartFormData",
|
||||
"FileFormName": "sharex",
|
||||
"URL": "$json:boxUrl$",
|
||||
"DeletionURL": "$json:manageUrl$"
|
||||
"URL": "{json:boxUrl}",
|
||||
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||
"DeletionURL": "{json:deleteUrl}",
|
||||
"ErrorMessage": "{json:error}"
|
||||
}
|
||||
|
||||
1
scripts/env/dev.env.example
vendored
1
scripts/env/dev.env.example
vendored
@@ -30,3 +30,4 @@ WARPBOX_USER_STORAGE_BACKEND=local
|
||||
WARPBOX_READ_TIMEOUT=15s
|
||||
WARPBOX_WRITE_TIMEOUT=60s
|
||||
WARPBOX_IDLE_TIMEOUT=120s
|
||||
WARPBOX_TRUSTED_PROXIES=
|
||||
|
||||
@@ -14,6 +14,11 @@ set -a
|
||||
source "${ENV_FILE}"
|
||||
set +a
|
||||
|
||||
if [[ -z "${APP_VERSION:-}" ]]; then
|
||||
APP_VERSION="$(git -C "${ROOT_DIR}" describe --tags --abbrev=0 2>/dev/null || printf 'dev')"
|
||||
export APP_VERSION
|
||||
fi
|
||||
|
||||
if [[ "${WARPBOX_DATA_DIR:-}" != /* ]]; then
|
||||
export WARPBOX_DATA_DIR="${ROOT_DIR}/${WARPBOX_DATA_DIR:-data}"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user