Compare commits
25 Commits
v0.0.1-tes
...
v0.2.6
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa2d9636b | |||
| cc91ce120d | |||
| 73bd14572d | |||
| 4eacb4cde2 | |||
| 71d9b9db7e | |||
| 01996c0445 | |||
| adb1a12dfd | |||
| 10ed806153 | |||
| 2d04a42736 | |||
| 42449b3322 | |||
| 1513030c2a | |||
| ac9b8232f3 | |||
| 704efb019c | |||
| 48d3c0475f | |||
| ffe4201f05 | |||
| df91fe9d3d | |||
| f1c67c455b | |||
| 61b7c283a4 | |||
| d99f8ee82a | |||
| 0503fad9af | |||
| 3423c141be | |||
| c3558fd353 | |||
| 830d2a885c | |||
| d77f164900 | |||
| 9a3cb90b17 |
18
.env.example
@@ -10,6 +10,24 @@ WARPBOX_CLEANUP_EVERY=1h
|
|||||||
WARPBOX_THUMBNAIL_ENABLED=true
|
WARPBOX_THUMBNAIL_ENABLED=true
|
||||||
WARPBOX_THUMBNAIL_EVERY=1m
|
WARPBOX_THUMBNAIL_EVERY=1m
|
||||||
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
||||||
|
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
|
||||||
|
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
|
||||||
|
WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
|
||||||
|
WARPBOX_USER_DAILY_UPLOAD_MB=8192
|
||||||
|
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
|
||||||
|
WARPBOX_USAGE_RETENTION_DAYS=30
|
||||||
|
WARPBOX_LOCAL_STORAGE_MAX_GB=100
|
||||||
|
WARPBOX_ANONYMOUS_MAX_DAYS=30
|
||||||
|
WARPBOX_USER_MAX_DAYS=90
|
||||||
|
WARPBOX_ANONYMOUS_DAILY_BOXES=100
|
||||||
|
WARPBOX_USER_DAILY_BOXES=250
|
||||||
|
WARPBOX_ANONYMOUS_ACTIVE_BOXES=500
|
||||||
|
WARPBOX_USER_ACTIVE_BOXES=1000
|
||||||
|
WARPBOX_SHORT_WINDOW_REQUESTS=60
|
||||||
|
WARPBOX_SHORT_WINDOW_SECONDS=60
|
||||||
|
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
|
||||||
|
WARPBOX_USER_STORAGE_BACKEND=local
|
||||||
WARPBOX_READ_TIMEOUT=15s
|
WARPBOX_READ_TIMEOUT=15s
|
||||||
WARPBOX_WRITE_TIMEOUT=60s
|
WARPBOX_WRITE_TIMEOUT=60s
|
||||||
WARPBOX_IDLE_TIMEOUT=120s
|
WARPBOX_IDLE_TIMEOUT=120s
|
||||||
|
WARPBOX_TRUSTED_PROXIES=
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -12,5 +12,8 @@ backend/static/uploads/*
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.prod.env
|
||||||
scripts/env/dev.env
|
scripts/env/dev.env
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
.claude
|
||||||
114
CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
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:**
|
||||||
|
```bash
|
||||||
|
/home/linuxbrew/.linuxbrew/bin/go
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run dev server:**
|
||||||
|
```bash
|
||||||
|
# First-time setup: copy the env template
|
||||||
|
cp scripts/env/dev.env.example scripts/env/dev.env
|
||||||
|
# Edit scripts/env/dev.env to set WARPBOX_ADMIN_TOKEN and other values, then:
|
||||||
|
./scripts/run/dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run directly (one-off):**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go run ./cmd/warpbox
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run all tests:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run a single test or package:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./libs/services/... -run TestDeleteTokenVerification
|
||||||
|
go test ./libs/handlers/... -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go build ./cmd/warpbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Warpbox is a self-hosted file-sharing app. All code lives under `backend/`. There is no frontend build step — the server renders Go templates and serves static assets directly.
|
||||||
|
|
||||||
|
### Startup flow
|
||||||
|
|
||||||
|
`cmd/warpbox/main.go` → `config.Load()` → `httpserver.New()` → `server.ListenAndServe()`
|
||||||
|
|
||||||
|
`httpserver.New()` wires everything together:
|
||||||
|
1. Creates `web.Renderer` (template engine)
|
||||||
|
2. Creates `UploadService` (opens bbolt DB, creates `files/` and `db/` dirs)
|
||||||
|
3. Creates `AuthService` (reuses same `*bbolt.DB`)
|
||||||
|
4. Creates `SettingsService` (reuses same `*bbolt.DB`)
|
||||||
|
5. Starts background jobs via `jobs.StartAll`
|
||||||
|
6. Creates `handlers.App` with all services
|
||||||
|
7. Registers all routes on a `http.ServeMux`
|
||||||
|
8. Wraps the mux in middleware chain: `Recoverer → RequestID → SecurityHeaders → Gzip → Logger`
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
The three services share a single bbolt database. Each owns distinct buckets:
|
||||||
|
|
||||||
|
| Service | Buckets |
|
||||||
|
|---|---|
|
||||||
|
| `UploadService` | `boxes` |
|
||||||
|
| `AuthService` | `users`, `user_emails`, `sessions`, `invites`, `collections` |
|
||||||
|
| `SettingsService` | `settings`, `usage` |
|
||||||
|
|
||||||
|
`UploadService` owns the DB handle. `AuthService` and `SettingsService` receive `uploadService.DB()`.
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
- **Box** — one upload session. Has expiry, optional download limit, optional password (SHA-256 salted hash), optional owner (`OwnerID`), optional collection. Stored as JSON in the `boxes` bucket.
|
||||||
|
- **File** — belongs to a Box. Stored on disk as `data/files/{boxID}/@each@{fileID}.ext`. Thumbnails at `@thumb@{fileID}.jpg`.
|
||||||
|
- Box metadata is also written to `data/files/{boxID}/.warpbox.box.json` on every save.
|
||||||
|
- **User** passwords are hashed with argon2id. Session tokens are SHA-256 hashed before storage.
|
||||||
|
- **Delete tokens** for anonymous boxes are one-time random IDs, stored only as a SHA-256 hash.
|
||||||
|
|
||||||
|
### Handlers
|
||||||
|
|
||||||
|
`handlers.App` holds all three services plus config, logger, and renderer. `RegisterRoutes` maps every URL pattern. Handler files are split by concern: `upload.go`, `download.go`, `dashboard.go` (user `/app`), `admin.go`, `auth.go`, `manage.go`, `pages.go`.
|
||||||
|
|
||||||
|
### Upload policy enforcement
|
||||||
|
|
||||||
|
`SettingsService` stores per-day usage records keyed by `ip:{ip}:{date}` or `user:{userID}:{date}`. The upload handler (`handlers/upload.go`) checks these against `UploadPolicySettings` before accepting a multipart form. Admins bypass all per-upload and daily limits.
|
||||||
|
|
||||||
|
### Background jobs
|
||||||
|
|
||||||
|
`jobs.StartAll` launches goroutines for:
|
||||||
|
- **Cleanup** (`WARPBOX_CLEANUP_ENABLED`): deletes expired boxes and boxes that hit their download limit.
|
||||||
|
- **Thumbnails** (`WARPBOX_THUMBNAIL_ENABLED`): generates JPEG thumbnails for image/video files that don't have one yet.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
All config comes from env vars via `config.Load()`. The dev script sources `scripts/env/dev.env`. `WARPBOX_BASE_URL` is required and must not be empty. Size values accept an optional `MB`/`Mb` suffix and support fractions (e.g. `0.5` = 512 KiB).
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Structured JSONL logs go to `data/logs/{YYYY-MM-DD}.log` via `log/slog`. Every log entry includes `source` (e.g. `"user-upload"`, `"admin"`) and `severity` fields. User-activity events include a numeric `code` field (e.g. `2001` = upload complete, `2101` = box deleted).
|
||||||
|
|
||||||
|
### Template rendering
|
||||||
|
|
||||||
|
`web.Renderer` parses all templates from `backend/templates/` at startup using `html/template`. Page data is passed as `web.PageData`. The base layout is `templates/layouts/base.html`. The current logged-in user is injected into every page render via `a.currentPublicUser(r)`.
|
||||||
|
|
||||||
|
## First-run bootstrap
|
||||||
|
|
||||||
|
On a fresh `data/` directory, visit `/register` to create the first admin account. After bootstrap, normal registration is closed. Admins create invite links from `/admin/users`. The `WARPBOX_ADMIN_TOKEN` env var provides emergency fallback access at `/admin/login`.
|
||||||
@@ -16,12 +16,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
|||||||
|
|
||||||
FROM alpine:3.22
|
FROM alpine:3.22
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates ffmpeg wget
|
RUN apk add --no-cache ca-certificates ffmpeg wget
|
||||||
|
|
||||||
ENV WARPBOX_ADDR=:8080 \
|
ENV WARPBOX_ADDR=:8080 \
|
||||||
WARPBOX_DATA_DIR=/data \
|
WARPBOX_DATA_DIR=/data \
|
||||||
WARPBOX_STATIC_DIR=/app/static \
|
WARPBOX_STATIC_DIR=/app/static \
|
||||||
WARPBOX_TEMPLATE_DIR=/app/templates
|
WARPBOX_TEMPLATE_DIR=/app/templates \
|
||||||
|
APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
147
README.md
@@ -13,6 +13,28 @@ The default server listens on `:8080`.
|
|||||||
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
|
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
|
||||||
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
|
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
|
||||||
|
|
||||||
|
Upload policy defaults are also configured in megabytes and can later be changed from
|
||||||
|
`/admin/settings`:
|
||||||
|
|
||||||
|
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
|
||||||
|
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
|
||||||
|
- `WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048`
|
||||||
|
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
|
||||||
|
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
|
||||||
|
- `WARPBOX_USAGE_RETENTION_DAYS=30`
|
||||||
|
- `WARPBOX_LOCAL_STORAGE_MAX_GB=100`
|
||||||
|
- `WARPBOX_ANONYMOUS_MAX_DAYS=30`
|
||||||
|
- `WARPBOX_USER_MAX_DAYS=90`
|
||||||
|
- `WARPBOX_ANONYMOUS_DAILY_BOXES=100`
|
||||||
|
- `WARPBOX_USER_DAILY_BOXES=250`
|
||||||
|
- `WARPBOX_ANONYMOUS_ACTIVE_BOXES=500`
|
||||||
|
- `WARPBOX_USER_ACTIVE_BOXES=1000`
|
||||||
|
- `WARPBOX_SHORT_WINDOW_REQUESTS=60`
|
||||||
|
- `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.
|
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.
|
The dev script resolves that path from the repository root.
|
||||||
|
|
||||||
@@ -20,7 +42,12 @@ Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs ca
|
|||||||
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
||||||
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
||||||
|
|
||||||
The basic admin console is available at `/admin`. Set `WARPBOX_ADMIN_TOKEN` and use that value to sign in.
|
On a fresh data directory, visit `/register` to create the first account. That first user becomes
|
||||||
|
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
|
||||||
|
links from `/admin/users`.
|
||||||
|
|
||||||
|
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
|
||||||
|
at `/admin/login` if you need to recover access without a user session.
|
||||||
|
|
||||||
For one-off Go commands, run them from the backend module:
|
For one-off Go commands, run them from the backend module:
|
||||||
|
|
||||||
@@ -48,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
|
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks
|
||||||
use `/health`.
|
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
|
## Layout
|
||||||
|
|
||||||
- `backend/cmd/warpbox` - main application entry point.
|
- `backend/cmd/warpbox` - main application entry point.
|
||||||
@@ -88,7 +182,7 @@ Curl and custom uploaders can use the same endpoint:
|
|||||||
# Terminal-friendly output: one plain box URL.
|
# Terminal-friendly output: one plain box URL.
|
||||||
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
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 \
|
curl -F sharex=@./screenshot.png \
|
||||||
-H 'Accept: application/json' \
|
-H 'Accept: application/json' \
|
||||||
http://localhost:8080/api/v1/upload
|
http://localhost:8080/api/v1/upload
|
||||||
@@ -96,14 +190,59 @@ curl -F sharex=@./screenshot.png \
|
|||||||
|
|
||||||
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
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.
|
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
|
||||||
|
|
||||||
|
- `/register` bootstraps the first admin account only when no users exist.
|
||||||
|
- `/login` and `/logout` provide cookie-based web sessions.
|
||||||
|
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
||||||
|
history, and flat collections. Uploading still happens from the homepage.
|
||||||
|
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
|
||||||
|
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is
|
||||||
|
stored with owner and optional collection metadata.
|
||||||
|
- Admin users are exempt from the global max upload size on the homepage upload flow. Future
|
||||||
|
per-user quotas should apply to this same upload path rather than creating a second uploader.
|
||||||
|
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
||||||
|
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.
|
||||||
|
The bbolt database and JSON logs remain local under `./data/db` and `./data/logs`.
|
||||||
|
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
|
||||||
|
tokens, thumbnails, and cleanup continue to work as before.
|
||||||
|
|
||||||
|
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
||||||
|
support will power public forgot-password and optional email delivery.
|
||||||
|
|
||||||
## Runtime Data
|
## Runtime Data
|
||||||
|
|
||||||
Warpbox keeps local runtime data under the configured data directory:
|
Warpbox keeps local runtime data under the configured data directory:
|
||||||
|
|
||||||
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents.
|
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents when the local backend is selected.
|
||||||
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available.
|
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews when the local backend is selected.
|
||||||
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
|
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
|
||||||
|
- `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.
|
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
||||||
|
|
||||||
## Static Asset Policy
|
## Static Asset Policy
|
||||||
|
|||||||
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`.
|
||||||
@@ -3,8 +3,32 @@ module warpbox.dev/backend
|
|||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0
|
||||||
|
github.com/minio/minio-go/v7 v7.2.0
|
||||||
|
github.com/pkg/sftp v1.13.10
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
|
golang.org/x/crypto v0.51.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.29.0 // indirect
|
require (
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/geoffgarside/ber v1.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/net v0.53.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,16 +1,82 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
|
||||||
|
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
|
||||||
|
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
|
||||||
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
|
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
|
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||||
|
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
|
github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
|
||||||
|
github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||||
|
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
|
||||||
|
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppName string
|
AppName string
|
||||||
|
AppVersion string
|
||||||
Environment string
|
Environment string
|
||||||
Addr string
|
Addr string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
@@ -22,17 +23,40 @@ type Config struct {
|
|||||||
ReadTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
|
TrustedProxies []string
|
||||||
JobsEnabled bool
|
JobsEnabled bool
|
||||||
CleanupEnabled bool
|
CleanupEnabled bool
|
||||||
CleanupEvery time.Duration
|
CleanupEvery time.Duration
|
||||||
ThumbnailEnabled bool
|
ThumbnailEnabled bool
|
||||||
ThumbnailEvery time.Duration
|
ThumbnailEvery time.Duration
|
||||||
MaxUploadSize int64
|
MaxUploadSize int64
|
||||||
|
DefaultSettings SettingsDefaults
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsDefaults struct {
|
||||||
|
AnonymousUploadsEnabled bool
|
||||||
|
AnonymousMaxUploadMB float64
|
||||||
|
AnonymousDailyUploadMB float64
|
||||||
|
UserDailyUploadMB float64
|
||||||
|
DefaultUserStorageMB float64
|
||||||
|
UsageRetentionDays int
|
||||||
|
LocalStorageMaxGB float64
|
||||||
|
AnonymousMaxDays int
|
||||||
|
UserMaxDays int
|
||||||
|
AnonymousDailyBoxes int
|
||||||
|
UserDailyBoxes int
|
||||||
|
AnonymousActiveBoxes int
|
||||||
|
UserActiveBoxes int
|
||||||
|
ShortWindowRequests int
|
||||||
|
ShortWindowSeconds int
|
||||||
|
AnonymousStorageBackend string
|
||||||
|
UserStorageBackend string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||||
|
AppVersion: envString("APP_VERSION", "dev"),
|
||||||
Environment: envString("WARPBOX_ENV", "development"),
|
Environment: envString("WARPBOX_ENV", "development"),
|
||||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||||
@@ -43,12 +67,32 @@ func Load() (Config, error) {
|
|||||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||||
|
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
||||||
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
||||||
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
||||||
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
||||||
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
||||||
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||||
|
DefaultSettings: SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||||
|
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),
|
||||||
|
AnonymousMaxDays: envInt("WARPBOX_ANONYMOUS_MAX_DAYS", 30),
|
||||||
|
UserMaxDays: envInt("WARPBOX_USER_MAX_DAYS", 90),
|
||||||
|
AnonymousDailyBoxes: envInt("WARPBOX_ANONYMOUS_DAILY_BOXES", 100),
|
||||||
|
UserDailyBoxes: envInt("WARPBOX_USER_DAILY_BOXES", 250),
|
||||||
|
AnonymousActiveBoxes: envInt("WARPBOX_ANONYMOUS_ACTIVE_BOXES", 500),
|
||||||
|
UserActiveBoxes: envInt("WARPBOX_USER_ACTIVE_BOXES", 1000),
|
||||||
|
ShortWindowRequests: envInt("WARPBOX_SHORT_WINDOW_REQUESTS", 60),
|
||||||
|
ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60),
|
||||||
|
AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_STORAGE_BACKEND", "local"),
|
||||||
|
UserStorageBackend: envString("WARPBOX_USER_STORAGE_BACKEND", "local"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.BaseURL == "" {
|
if cfg.BaseURL == "" {
|
||||||
@@ -57,6 +101,22 @@ func Load() (Config, error) {
|
|||||||
if cfg.MaxUploadSize <= 0 {
|
if cfg.MaxUploadSize <= 0 {
|
||||||
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
||||||
}
|
}
|
||||||
|
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 ||
|
||||||
|
cfg.DefaultSettings.AnonymousMaxDays <= 0 ||
|
||||||
|
cfg.DefaultSettings.UserMaxDays <= 0 ||
|
||||||
|
cfg.DefaultSettings.AnonymousDailyBoxes <= 0 ||
|
||||||
|
cfg.DefaultSettings.UserDailyBoxes <= 0 ||
|
||||||
|
cfg.DefaultSettings.AnonymousActiveBoxes <= 0 ||
|
||||||
|
cfg.DefaultSettings.UserActiveBoxes <= 0 ||
|
||||||
|
cfg.DefaultSettings.ShortWindowRequests <= 0 ||
|
||||||
|
cfg.DefaultSettings.ShortWindowSeconds <= 0 {
|
||||||
|
return Config{}, fmt.Errorf("upload policy settings must be positive, with -1 allowed for upload MB limits")
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -109,6 +169,34 @@ func envBool(key string, fallback bool) bool {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envInt(key string, fallback int) int {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
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 {
|
func envMegabytes(key string, fallback float64) int64 {
|
||||||
value := strings.TrimSpace(os.Getenv(key))
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
@@ -122,7 +210,56 @@ func envMegabytes(key string, fallback float64) int64 {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func envMegabytesFloat(key string, fallback float64) float64 {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
parsed, err := parseMegabytesFloat(value)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
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 == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
normalized := strings.TrimSpace(value)
|
||||||
|
normalized = strings.TrimSuffix(normalized, "GB")
|
||||||
|
normalized = strings.TrimSuffix(normalized, "Gb")
|
||||||
|
normalized = strings.TrimSuffix(normalized, "gb")
|
||||||
|
normalized = strings.TrimSpace(normalized)
|
||||||
|
parsed, err := strconv.ParseFloat(normalized, 64)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
func parseMegabytes(value string) (int64, error) {
|
func parseMegabytes(value string) (int64, error) {
|
||||||
|
sizeMB, err := parseMegabytesFloat(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return megabytesToBytes(sizeMB), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMegabytesFloat(value string) (float64, error) {
|
||||||
normalized := strings.TrimSpace(value)
|
normalized := strings.TrimSpace(value)
|
||||||
normalized = strings.TrimSuffix(normalized, "MB")
|
normalized = strings.TrimSuffix(normalized, "MB")
|
||||||
normalized = strings.TrimSuffix(normalized, "Mb")
|
normalized = strings.TrimSuffix(normalized, "Mb")
|
||||||
@@ -137,7 +274,36 @@ func parseMegabytes(value string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("megabyte value must be positive")
|
return 0, fmt.Errorf("megabyte value must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
return megabytesToBytes(sizeMB), nil
|
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 {
|
func megabytesToBytes(sizeMB float64) int64 {
|
||||||
|
|||||||
1205
backend/libs/handlers/accounts_test.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,10 +17,11 @@ type apiDocsData struct {
|
|||||||
ShareXExampleURL string
|
ShareXExampleURL string
|
||||||
ShareXDownloadURL string
|
ShareXDownloadURL string
|
||||||
ShareXFileFieldName string
|
ShareXFileFieldName string
|
||||||
|
ShareXGroupWindow string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||||
a.renderer.Render(w, http.StatusOK, "api.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
|
||||||
Title: "API documentation",
|
Title: "API documentation",
|
||||||
Description: "Curl and ShareX upload examples for Warpbox.",
|
Description: "Curl and ShareX upload examples for Warpbox.",
|
||||||
Data: apiDocsData{
|
Data: apiDocsData{
|
||||||
@@ -33,6 +34,7 @@ func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
|||||||
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
|
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
|
||||||
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
|
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
|
||||||
ShareXFileFieldName: "sharex",
|
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",
|
"RequestURL": a.cfg.BaseURL + "/api/v1/upload",
|
||||||
"Headers": map[string]string{
|
"Headers": map[string]string{
|
||||||
"Accept": "application/json",
|
"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",
|
"Body": "MultipartFormData",
|
||||||
"FileFormName": "sharex",
|
"FileFormName": "sharex",
|
||||||
"URL": "$json:boxUrl$",
|
"URL": "{json:boxUrl}",
|
||||||
"DeletionURL": "$json:manageUrl$",
|
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||||
|
"DeletionURL": "{json:deleteUrl}",
|
||||||
|
"ErrorMessage": "{json:error}",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,22 +116,24 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"},
|
"required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"boxId": map[string]any{"type": "string"},
|
"boxId": map[string]any{"type": "string"},
|
||||||
"boxUrl": map[string]any{"type": "string", "format": "uri"},
|
"boxUrl": map[string]any{"type": "string", "format": "uri"},
|
||||||
"zipUrl": map[string]any{"type": "string", "format": "uri"},
|
"zipUrl": map[string]any{"type": "string", "format": "uri"},
|
||||||
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
|
"thumbnailUrl": map[string]any{"type": "string", "format": "uri", "description": "Thumbnail of the most recently uploaded file (placeholder until generated)."},
|
||||||
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer POST URL for deleting this upload. Returned only at upload time."},
|
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
|
||||||
"expiresAt": map[string]any{"type": "string", "format": "date-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{
|
"files": map[string]any{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": map[string]any{
|
"items": map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": []string{"id", "name", "size", "url"},
|
"required": []string{"id", "name", "size", "url"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"id": map[string]any{"type": "string"},
|
"id": map[string]any{"type": "string"},
|
||||||
"name": map[string]any{"type": "string"},
|
"name": map[string]any{"type": "string"},
|
||||||
"size": map[string]any{"type": "string"},
|
"size": map[string]any{"type": "string"},
|
||||||
"url": map[string]any{"type": "string", "format": "uri"},
|
"url": map[string]any{"type": "string", "format": "uri"},
|
||||||
|
"thumbnailUrl": map[string]any{"type": "string", "format": "uri"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,40 +10,121 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
renderer *web.Renderer
|
renderer *web.Renderer
|
||||||
uploadService *services.UploadService
|
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) *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{
|
return &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
|
authService: authService,
|
||||||
|
settingsService: settingsService,
|
||||||
|
banService: banService,
|
||||||
|
rateLimiter: newRateLimiter(),
|
||||||
|
uploadGroups: newUploadGrouper(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, page string, data web.PageData) {
|
||||||
|
if data.CurrentUser == nil {
|
||||||
|
data.CurrentUser = a.currentPublicUser(r)
|
||||||
|
}
|
||||||
|
data.CSRFToken = a.csrfToken(w, r)
|
||||||
|
a.renderer.Render(w, status, page, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /", a.Home)
|
mux.HandleFunc("GET /", a.Home)
|
||||||
mux.HandleFunc("GET /api", a.APIDocs)
|
mux.HandleFunc("GET /api", a.APIDocs)
|
||||||
|
mux.HandleFunc("GET /register", a.Register)
|
||||||
|
mux.HandleFunc("POST /register", a.RegisterPost)
|
||||||
|
mux.HandleFunc("GET /login", a.Login)
|
||||||
|
mux.HandleFunc("POST /login", a.LoginPost)
|
||||||
|
mux.HandleFunc("POST /logout", a.Logout)
|
||||||
|
mux.HandleFunc("GET /invite/{token}", a.Invite)
|
||||||
|
mux.HandleFunc("POST /invite/{token}", a.InvitePost)
|
||||||
|
mux.HandleFunc("GET /app", a.Dashboard)
|
||||||
|
mux.HandleFunc("POST /app/collections", a.CreateCollection)
|
||||||
|
mux.HandleFunc("POST /app/boxes/{boxID}/rename", a.RenameUserBox)
|
||||||
|
mux.HandleFunc("POST /app/boxes/{boxID}/move", a.MoveUserBox)
|
||||||
|
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("GET /admin/login", a.AdminLogin)
|
||||||
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
|
||||||
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
|
||||||
mux.HandleFunc("GET /admin", a.AdminDashboard)
|
mux.HandleFunc("GET /admin", a.AdminDashboard)
|
||||||
mux.HandleFunc("GET /admin/files", a.AdminFiles)
|
mux.HandleFunc("GET /admin/files", a.AdminFiles)
|
||||||
|
mux.HandleFunc("GET /admin/users", a.AdminUsers)
|
||||||
|
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("GET /admin/storage/new", a.AdminNewStorage)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/contabo", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/sftp", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/smb", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/webdav", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/s3", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/contabo", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/sftp", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/smb", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/webdav", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("GET /admin/storage/{backendID}/edit", a.AdminEditStorageForm)
|
||||||
|
mux.HandleFunc("GET /admin/storage/{backendID}/tests", a.AdminStorageTests)
|
||||||
|
mux.HandleFunc("GET /admin/storage/{backendID}/tests.json", a.AdminStorageTestsJSON)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/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)
|
||||||
|
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
|
||||||
|
mux.HandleFunc("POST /admin/users/{userID}/edit", a.AdminUpdateUser)
|
||||||
|
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}/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("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
|
||||||
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
|
||||||
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
|
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
|
||||||
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
|
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
|
||||||
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
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("POST /d/{boxID}/unlock", a.UnlockBox)
|
||||||
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
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 /health", a.Health)
|
||||||
mux.HandleFunc("GET /healthz", a.Health)
|
mux.HandleFunc("GET /healthz", a.Health)
|
||||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
mux.HandleFunc("GET /api/v1/health", a.Health)
|
||||||
|
|||||||
333
backend/libs/handlers/auth.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
"warpbox.dev/backend/libs/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const userSessionCookieName = "warpbox_session"
|
||||||
|
|
||||||
|
type authPageData struct {
|
||||||
|
Mode string
|
||||||
|
Token string
|
||||||
|
Email string
|
||||||
|
IsReset bool
|
||||||
|
Error string
|
||||||
|
ReturnPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
available, err := a.authService.BootstrapAvailable()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to check registration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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, "ip", uploadClientIP(r))
|
||||||
|
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := a.currentUser(r); ok {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next := r.FormValue("next")
|
||||||
|
if next == "" {
|
||||||
|
next = "/app"
|
||||||
|
}
|
||||||
|
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"), "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, "ip", uploadClientIP(r))
|
||||||
|
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
a.clearUserSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
invite, err := a.authService.InviteByToken(r.PathValue("token"))
|
||||||
|
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
||||||
|
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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, "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.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: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ChangePassword(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 {
|
||||||
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
|
||||||
|
a.renderPage(w, r, status, "auth.html", web.PageData{
|
||||||
|
Title: "Account",
|
||||||
|
Description: "Sign in to Warpbox.",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
|
||||||
|
_, token, err := a.authService.Login(email, password)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.setUserSessionCookie(w, r, token)
|
||||||
|
http.Redirect(w, r, path, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
|
user, _, err := a.authService.UserForSession(cookie.Value)
|
||||||
|
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) {
|
||||||
|
user, ok := a.currentUser(r)
|
||||||
|
if ok {
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
||||||
|
return services.User{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: userSessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
Expires: time.Now().Add(30 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: userSessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeReturnPath(path string) string {
|
||||||
|
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
|
||||||
|
return "/app"
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
188
backend/libs/handlers/dashboard.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
"warpbox.dev/backend/libs/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
type dashboardData struct {
|
||||||
|
User services.PublicUser
|
||||||
|
Collections []collectionView
|
||||||
|
Boxes []userBoxView
|
||||||
|
StorageUsed string
|
||||||
|
MaxUploadSize string
|
||||||
|
Selected string
|
||||||
|
LastInviteURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type collectionView struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type userBoxView struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
CollectionID string
|
||||||
|
CollectionName string
|
||||||
|
FileCount int
|
||||||
|
Size string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collections, err := a.authService.ListCollections(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load collections", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectionNames := map[string]string{}
|
||||||
|
collectionViews := make([]collectionView, 0, len(collections))
|
||||||
|
for _, collection := range collections {
|
||||||
|
collectionNames[collection.ID] = collection.Name
|
||||||
|
collectionViews = append(collectionViews, collectionView{ID: collection.ID, Name: collection.Name})
|
||||||
|
}
|
||||||
|
boxes, err := a.uploadService.UserBoxes(user.ID, collectionNames)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
storageUsed, err := a.uploadService.UserStorageUsed(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load storage usage", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := r.URL.Query().Get("collection")
|
||||||
|
boxViews := make([]userBoxView, 0, len(boxes))
|
||||||
|
for _, row := range boxes {
|
||||||
|
if selected != "" && row.Box.CollectionID != selected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := row.Box.Title
|
||||||
|
if title == "" {
|
||||||
|
title = fmt.Sprintf("%d file upload", len(row.Box.Files))
|
||||||
|
}
|
||||||
|
boxViews = append(boxViews, userBoxView{
|
||||||
|
ID: row.Box.ID,
|
||||||
|
Title: title,
|
||||||
|
CollectionID: row.Box.CollectionID,
|
||||||
|
CollectionName: row.CollectionName,
|
||||||
|
FileCount: len(row.Box.Files),
|
||||||
|
Size: row.TotalSizeLabel,
|
||||||
|
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
|
||||||
|
ExpiresAt: boxExpiryLabel(row.Box.ExpiresAt, "Jan 2 15:04"),
|
||||||
|
URL: "/d/" + row.Box.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderPage(w, r, http.StatusOK, "dashboard.html", web.PageData{
|
||||||
|
Title: "My files",
|
||||||
|
Description: "Your Warpbox personal file space.",
|
||||||
|
CurrentUser: a.authService.PublicUser(user),
|
||||||
|
Data: dashboardData{
|
||||||
|
User: a.authService.PublicUser(user),
|
||||||
|
Collections: collectionViews,
|
||||||
|
Boxes: boxViews,
|
||||||
|
StorageUsed: helpers.FormatBytes(storageUsed),
|
||||||
|
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
||||||
|
Selected: selected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CreateCollection(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 {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) RenameUserBox(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 {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) MoveUserBox(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 {
|
||||||
|
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUserBoxError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if os.IsPermission(err) {
|
||||||
|
http.Error(w, "not allowed", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "unable to update box", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -51,11 +53,13 @@ type previewPageData struct {
|
|||||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
if err := a.uploadService.CanDownload(box); err != nil {
|
||||||
a.renderer.Render(w, http.StatusForbidden, "download.html", web.PageData{
|
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",
|
Title: "Download unavailable",
|
||||||
Description: "This Warpbox link is no longer available.",
|
Description: "This Warpbox link is no longer available.",
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
@@ -74,9 +78,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
|
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
||||||
Title: "Download files",
|
title := "Shared files on Warpbox"
|
||||||
Description: "Download files shared through 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: title,
|
||||||
|
Description: description,
|
||||||
|
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
Files: files,
|
Files: files,
|
||||||
@@ -85,9 +98,17 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
Obfuscated: box.Obfuscate,
|
Obfuscated: box.Obfuscate,
|
||||||
DownloadCount: box.DownloadCount,
|
DownloadCount: box.DownloadCount,
|
||||||
MaxDownloads: box.MaxDownloads,
|
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) {
|
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -107,7 +128,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
@@ -118,6 +139,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
DownloadURL: view.DownloadURL,
|
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) {
|
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -126,11 +148,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
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)
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
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) {
|
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -139,16 +163,32 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
path := a.uploadService.ThumbnailPath(box, file)
|
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||||
if path == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
http.ServeFile(w, r, path)
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
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) {
|
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -162,7 +202,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
|
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)
|
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -175,23 +215,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
Secure: r.TLS != nil,
|
Secure: r.TLS != nil,
|
||||||
Expires: box.ExpiresAt,
|
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)
|
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) {
|
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, services.File{}, false
|
return services.Box{}, services.File{}, false
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
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))
|
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||||
return services.Box{}, services.File{}, false
|
return services.Box{}, services.File{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, services.File{}, false
|
return services.Box{}, services.File{}, false
|
||||||
}
|
}
|
||||||
@@ -199,42 +242,55 @@ 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) {
|
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
||||||
path := a.uploadService.FilePath(box, file)
|
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
|
||||||
source, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
stat, err := source.Stat()
|
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", file.ContentType)
|
w.Header().Set("Content-Type", file.ContentType)
|
||||||
if attachment {
|
if attachment {
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
||||||
}
|
}
|
||||||
http.ServeContent(w, r, file.Name, stat.ModTime(), source)
|
if seeker, ok := object.Body.(io.ReadSeeker); ok {
|
||||||
|
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
|
||||||
|
} else {
|
||||||
|
if object.Size > 0 {
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", object.Size))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.Copy(w, object.Body)
|
||||||
|
}
|
||||||
|
|
||||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
|
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||||
|
data, err := io.ReadAll(source)
|
||||||
|
if err != nil {
|
||||||
|
return bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.CanDownload(box); err != nil {
|
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))
|
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
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)
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -250,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) {
|
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.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 {
|
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||||
@@ -280,6 +337,21 @@ func unlockCookieName(boxID string) string {
|
|||||||
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
|
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 {
|
func absoluteURL(r *http.Request, path string) string {
|
||||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderer.Render(w, http.StatusOK, "manage.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "manage.html", web.PageData{
|
||||||
Title: "Manage upload",
|
Title: "Manage upload",
|
||||||
Description: "Delete this anonymous Warpbox upload.",
|
Description: "Delete this anonymous Warpbox upload.",
|
||||||
Data: a.managePageData(box, r.PathValue("token")),
|
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) {
|
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -40,15 +41,16 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
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)
|
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
||||||
a.renderer.Render(w, http.StatusOK, "manage_deleted.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "manage_deleted.html", web.PageData{
|
||||||
Title: "Upload deleted",
|
Title: "Upload deleted",
|
||||||
Description: "This Warpbox upload has been deleted.",
|
Description: "This Warpbox upload has been deleted.",
|
||||||
Data: boxView{ID: r.PathValue("boxID")},
|
Data: boxView{ID: r.PathValue("boxID")},
|
||||||
@@ -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) {
|
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
|
||||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, false
|
return services.Box{}, false
|
||||||
}
|
}
|
||||||
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
|
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)
|
http.NotFound(w, r)
|
||||||
return services.Box{}, false
|
return services.Box{}, false
|
||||||
}
|
}
|
||||||
@@ -78,7 +82,7 @@ func (a *App) managePageData(box services.Box, token string) managePageData {
|
|||||||
Token: token,
|
Token: token,
|
||||||
FileCount: len(box.Files),
|
FileCount: len(box.Files),
|
||||||
TotalSize: helpers.FormatBytes(totalSize),
|
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,
|
DownloadCount: box.DownloadCount,
|
||||||
MaxDownloads: box.MaxDownloads,
|
MaxDownloads: box.MaxDownloads,
|
||||||
Protected: a.uploadService.IsProtected(box),
|
Protected: a.uploadService.IsProtected(box),
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -2,20 +2,169 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
type homeData struct {
|
type homeData struct {
|
||||||
MaxUploadSize string
|
MaxUploadSize string
|
||||||
|
LimitSummary string
|
||||||
|
Collections []collectionView
|
||||||
|
IsAdmin bool
|
||||||
|
AnonymousOpen bool
|
||||||
|
ExpiryOptions []expiryOption
|
||||||
|
DefaultExpiryMinutes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type expiryOption struct {
|
||||||
|
Minutes int
|
||||||
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||||
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
|
currentUser := a.currentPublicUser(r)
|
||||||
|
var collections []collectionView
|
||||||
|
var isAdmin bool
|
||||||
|
var user services.User
|
||||||
|
var loggedIn bool
|
||||||
|
if current, ok := a.currentUser(r); ok {
|
||||||
|
user = current
|
||||||
|
loggedIn = true
|
||||||
|
isAdmin = user.Role == services.UserRoleAdmin
|
||||||
|
userCollections, err := a.authService.ListCollections(user.ID)
|
||||||
|
if err == nil {
|
||||||
|
collections = make([]collectionView, 0, len(userCollections))
|
||||||
|
for _, collection := range userCollections {
|
||||||
|
collections = append(collections, collectionView{ID: collection.ID, Name: collection.Name})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
|
||||||
|
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",
|
Title: "Upload your files",
|
||||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
||||||
|
CurrentUser: currentUser,
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
|
MaxUploadSize: maxUploadSize,
|
||||||
|
LimitSummary: limitSummary,
|
||||||
|
Collections: collections,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
AnonymousOpen: settings.AnonymousUploadsEnabled,
|
||||||
|
ExpiryOptions: expiryOptions,
|
||||||
|
DefaultExpiryMinutes: defaultExpiry,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// homeExpiryOptions builds the expiry ladder offered on the upload form, capped to
|
||||||
|
// the viewer's effective maximum retention. Admins have no cap (the dropdown is
|
||||||
|
// still capped at 365 days for sanity; the API accepts any value for admins).
|
||||||
|
func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) ([]expiryOption, int) {
|
||||||
|
maxDays := settings.AnonymousMaxDays
|
||||||
|
unlimited := false
|
||||||
|
switch {
|
||||||
|
case isAdmin:
|
||||||
|
unlimited = true
|
||||||
|
case loggedIn:
|
||||||
|
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
|
||||||
|
// 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."
|
||||||
|
}
|
||||||
|
if !loggedIn {
|
||||||
|
if !settings.AnonymousUploadsEnabled {
|
||||||
|
return "Anonymous uploads disabled", "Sign in to upload files."
|
||||||
|
}
|
||||||
|
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
|
||||||
|
}
|
||||||
|
policy := a.settingsService.EffectivePolicyForUser(settings, user)
|
||||||
|
maxUpload := a.uploadService.MaxUploadSizeLabel()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
106
backend/libs/handlers/security.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
const csrfCookieName = "warpbox_csrf"
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[string]rateRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateRecord struct {
|
||||||
|
StartedAt time.Time
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter() *rateLimiter {
|
||||||
|
return &rateLimiter{records: make(map[string]rateRecord)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *rateLimiter) Allow(key string, limit int, window time.Duration, now time.Time) bool {
|
||||||
|
if limit <= 0 || window <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
record := l.records[key]
|
||||||
|
if record.StartedAt.IsZero() || now.Sub(record.StartedAt) >= window {
|
||||||
|
l.records[key] = rateRecord{StartedAt: now, Count: 1}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
record.Count++
|
||||||
|
l.records[key] = record
|
||||||
|
return record.Count <= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) csrfToken(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
token := randomToken(32)
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: csrfCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
Expires: time.Now().Add(12 * time.Hour),
|
||||||
|
})
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cookie, err := r.Cookie(csrfCookieName)
|
||||||
|
if err != nil || cookie.Value == "" || r.FormValue("csrf_token") != cookie.Value {
|
||||||
|
http.Error(w, "invalid form token", http.StatusForbidden)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(byteCount int) string {
|
||||||
|
data := make([]byte, byteCount)
|
||||||
|
if _, err := rand.Read(data); err != nil {
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().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) {
|
func TestSetStaticCacheHeaders(t *testing.T) {
|
||||||
tests := map[string]string{
|
tests := map[string]string{
|
||||||
"/static/css/app.css": "public, max-age=86400",
|
"/static/css/00-base.css": "public, max-age=86400",
|
||||||
"/static/js/app.js": "public, max-age=86400",
|
"/static/js/00-utils.js": "public, max-age=86400",
|
||||||
"/static/img/preview.webp": "public, max-age=31536000, immutable",
|
"/static/img/preview.webp": "public, max-age=31536000, immutable",
|
||||||
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
|
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
|
||||||
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
|
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
"warpbox.dev/backend/libs/jobs"
|
"warpbox.dev/backend/libs/jobs"
|
||||||
@@ -14,25 +16,132 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
|
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||||
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil {
|
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 {
|
||||||
|
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
|
||||||
|
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
|
||||||
|
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 && 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
files := uploadFiles(r)
|
files := uploadFiles(r)
|
||||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
totalBytes := totalUploadBytes(files)
|
||||||
MaxDays: parseInt(r.FormValue("max_days")),
|
var ownerID string
|
||||||
|
var collectionID string
|
||||||
|
if loggedIn {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 = 7
|
||||||
|
if effectivePolicy.MaxDays > 0 && effectivePolicy.MaxDays < maxDays {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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")),
|
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||||
Password: r.FormValue("password"),
|
Password: r.FormValue("password"),
|
||||||
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
|
||||||
})
|
OwnerID: ownerID,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
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 {
|
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())
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
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 {
|
||||||
|
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4403, "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
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) {
|
if wantsJSON(r) {
|
||||||
helpers.WriteJSON(w, http.StatusCreated, result)
|
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||||
@@ -44,6 +153,228 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = fmt.Fprintln(w, result.BoxURL)
|
_, _ = 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, ""
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if policy.MaxUploadMB > 0 {
|
||||||
|
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Size > maxBytes {
|
||||||
|
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !loggedIn {
|
||||||
|
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||||
|
}
|
||||||
|
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||||
|
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
|
||||||
|
}
|
||||||
|
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
||||||
|
return status, message
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := a.settingsService.UsageForUser(user.ID, now)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||||
|
}
|
||||||
|
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||||
|
return http.StatusTooManyRequests, "daily upload limit reached"
|
||||||
|
}
|
||||||
|
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "storage quota could not be checked"
|
||||||
|
}
|
||||||
|
if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
|
||||||
|
return http.StatusRequestEntityTooLarge, "storage quota reached"
|
||||||
|
}
|
||||||
|
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
||||||
|
return status, message
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
|
||||||
|
}
|
||||||
|
return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
|
||||||
|
if loggedIn {
|
||||||
|
return a.settingsService.EffectivePolicyForUser(settings, user)
|
||||||
|
}
|
||||||
|
return a.settingsService.EffectivePolicyForAnonymous(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) checkStorageBackendCapacity(backendID string, settings services.UploadPolicySettings, totalBytes int64) (int, string) {
|
||||||
|
if backendID != services.StorageBackendLocal {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
backend, err := a.uploadService.Storage().Backend(services.StorageBackendLocal)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "storage backend could not be checked"
|
||||||
|
}
|
||||||
|
used, err := backend.Usage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "storage backend usage could not be checked"
|
||||||
|
}
|
||||||
|
if used+totalBytes > services.GigabytesToBytes(settings.LocalStorageMaxGB) {
|
||||||
|
return http.StatusRequestEntityTooLarge, "local storage limit reached"
|
||||||
|
}
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
|
||||||
|
if policy.MaxUploadMB < 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if loggedIn && policy.MaxUploadMB <= 0 {
|
||||||
|
return fallback * 8
|
||||||
|
}
|
||||||
|
if policy.MaxUploadMB > 0 {
|
||||||
|
return services.MegabytesToBytes(policy.MaxUploadMB) * 8
|
||||||
|
}
|
||||||
|
return fallback * 8
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadClientIP(r *http.Request) string {
|
||||||
|
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 {
|
||||||
|
if loggedIn {
|
||||||
|
return "user:" + user.ID
|
||||||
|
}
|
||||||
|
return "ip:" + uploadClientIP(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, file := range files {
|
||||||
|
total += file.Size
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
func parseInt(value string) int {
|
func parseInt(value string) int {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
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
@@ -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,22 +179,46 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
AppName: "warpbox.dev",
|
AppName: "warpbox.dev",
|
||||||
|
AppVersion: "test",
|
||||||
BaseURL: "http://example.test",
|
BaseURL: "http://example.test",
|
||||||
DataDir: filepath.Join(root, "data"),
|
DataDir: filepath.Join(root, "data"),
|
||||||
StaticDir: staticDir,
|
StaticDir: staticDir,
|
||||||
TemplateDir: templateDir,
|
TemplateDir: templateDir,
|
||||||
MaxUploadSize: 1024 * 1024,
|
MaxUploadSize: 1024 * 1024,
|
||||||
|
DefaultSettings: config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 1,
|
||||||
|
AnonymousDailyUploadMB: 8,
|
||||||
|
UserDailyUploadMB: 8,
|
||||||
|
DefaultUserStorageMB: 16,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewUploadService returned error: %v", err)
|
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 {
|
if err != nil {
|
||||||
service.Close()
|
service.Close()
|
||||||
t.Fatalf("NewRenderer returned error: %v", err)
|
t.Fatalf("NewRenderer returned error: %v", err)
|
||||||
}
|
}
|
||||||
return NewApp(cfg, logger, renderer, service), func() {
|
authService, err := services.NewAuthService(service.DB(), cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("NewAuthService returned error: %v", err)
|
||||||
|
}
|
||||||
|
settingsService, err := services.NewSettingsService(service.DB(), cfg.DefaultSettings)
|
||||||
|
if err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||||
|
}
|
||||||
|
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 {
|
if err := service.Close(); err != nil {
|
||||||
t.Fatalf("Close returned error: %v", err)
|
t.Fatalf("Close returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -237,6 +261,29 @@ func multipartUploadRequest(t *testing.T, path, field, filename, body string) *h
|
|||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func multipartUploadRequestWithField(t *testing.T, path, field, filename, body, extraName, extraValue string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
var payload bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&payload)
|
||||||
|
part, err := writer.CreateFormFile(field, filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write([]byte(body)); err != nil {
|
||||||
|
t.Fatalf("part.Write returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.WriteField(extraName, extraValue); err != nil {
|
||||||
|
t.Fatalf("WriteField returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("writer.Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, path, &payload)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
func tokenFromURL(t *testing.T, value string) string {
|
func tokenFromURL(t *testing.T, value string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
parts := strings.Split(strings.TrimRight(value, "/"), "/")
|
parts := strings.Split(strings.TrimRight(value, "/"), "/")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,23 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stopJobs := jobs.StartAll(cfg, logger, uploadService)
|
authService, err := services.NewAuthService(uploadService.DB(), cfg.BaseURL)
|
||||||
app := handlers.NewApp(cfg, logger, renderer, uploadService)
|
if err != nil {
|
||||||
|
uploadService.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settingsService, err := services.NewSettingsService(uploadService.DB(), cfg.DefaultSettings)
|
||||||
|
if err != nil {
|
||||||
|
uploadService.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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()
|
router := http.NewServeMux()
|
||||||
app.RegisterRoutes(router)
|
app.RegisterRoutes(router)
|
||||||
@@ -34,7 +49,9 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
middleware.RequestID,
|
middleware.RequestID,
|
||||||
middleware.SecurityHeaders,
|
middleware.SecurityHeaders,
|
||||||
middleware.Gzip,
|
middleware.Gzip,
|
||||||
|
middleware.ClientIP(cfg.TrustedProxies),
|
||||||
middleware.Logger(logger),
|
middleware.Logger(logger),
|
||||||
|
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
||||||
)
|
)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"warpbox.dev/backend/libs/services"
|
"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{
|
return job{
|
||||||
name: "cleanup",
|
name: "cleanup",
|
||||||
enabled: cfg.CleanupEnabled,
|
enabled: cfg.CleanupEnabled,
|
||||||
@@ -22,10 +22,24 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
|
|||||||
if cleaned > 0 {
|
if cleaned > 0 {
|
||||||
logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
|
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) {
|
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||||
boxes, err := uploadService.ListBoxes(0)
|
boxes, err := uploadService.ListBoxes(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ type job struct {
|
|||||||
run func()
|
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 {
|
if !cfg.JobsEnabled {
|
||||||
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
|
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
stops := []func(){
|
stops := []func(){
|
||||||
start(newCleanupJob(cfg, logger, uploadService), logger),
|
start(newCleanupJob(cfg, logger, uploadService, banService), logger),
|
||||||
start(newThumbnailsJob(cfg, logger, uploadService), logger),
|
start(newThumbnailsJob(cfg, logger, uploadService), logger),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"image"
|
"image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -17,7 +20,7 @@ import (
|
|||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type thumbnailJobResult struct {
|
type ThumbnailJobResult struct {
|
||||||
Scanned int
|
Scanned int
|
||||||
Generated int
|
Generated int
|
||||||
Failed int
|
Failed int
|
||||||
@@ -60,13 +63,17 @@ func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *ser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (thumbnailJobResult, error) {
|
func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
|
||||||
|
return generateMissingThumbnails(uploadService, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
|
||||||
boxes, err := uploadService.ListBoxes(0)
|
boxes, err := uploadService.ListBoxes(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thumbnailJobResult{}, err
|
return ThumbnailJobResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var result thumbnailJobResult
|
var result ThumbnailJobResult
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
for _, box := range boxes {
|
for _, box := range boxes {
|
||||||
if !box.ExpiresAt.After(now) {
|
if !box.ExpiresAt.After(now) {
|
||||||
@@ -85,8 +92,8 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (thumbnailJobResult, error) {
|
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
|
||||||
var result thumbnailJobResult
|
var result ThumbnailJobResult
|
||||||
if !box.ExpiresAt.After(time.Now().UTC()) {
|
if !box.ExpiresAt.After(time.Now().UTC()) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -129,43 +136,71 @@ func needsThumbnail(file services.File) bool {
|
|||||||
|
|
||||||
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
||||||
thumbnailPath := uploadService.ThumbnailPath(box, services.File{Thumbnail: thumbnailName})
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
sourcePath := uploadService.FilePath(box, file)
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(file.ContentType, "image/"):
|
case strings.HasPrefix(file.ContentType, "image/"):
|
||||||
return thumbnailName, createImageThumbnail(sourcePath, thumbnailPath)
|
data, err := createImageThumbnail(object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return thumbnailName, err
|
||||||
case strings.HasPrefix(file.ContentType, "video/"):
|
case strings.HasPrefix(file.ContentType, "video/"):
|
||||||
return thumbnailName, createVideoThumbnail(sourcePath, thumbnailPath)
|
data, err := createVideoThumbnail(object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return thumbnailName, err
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createImageThumbnail(sourcePath, targetPath string) error {
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||||
source, err := os.Open(sourcePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
|
|
||||||
img, _, err := image.Decode(source)
|
img, _, err := image.Decode(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb := resizeNearest(img, 360, 240)
|
thumb := resizeNearest(img, 360, 240)
|
||||||
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
var target bytes.Buffer
|
||||||
|
err = jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer target.Close()
|
return target.Bytes(), nil
|
||||||
|
|
||||||
return jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createVideoThumbnail(sourcePath, targetPath string) error {
|
func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
||||||
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourcePath, "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run()
|
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(sourceFile.Name())
|
||||||
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
|
sourceFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetPath := targetFile.Name()
|
||||||
|
targetFile.Close()
|
||||||
|
defer os.Remove(targetPath)
|
||||||
|
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.ReadFile(targetPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||||
|
|||||||
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
@@ -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
@@ -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"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type statusRecorder struct {
|
type statusRecorder struct {
|
||||||
@@ -38,6 +40,10 @@ func Logger(logger *slog.Logger) Middleware {
|
|||||||
if status == 0 {
|
if status == 0 {
|
||||||
status = http.StatusOK
|
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",
|
logger.Info("http request",
|
||||||
"source", "http",
|
"source", "http",
|
||||||
@@ -49,6 +55,7 @@ func Logger(logger *slog.Logger) Middleware {
|
|||||||
"bytes", recorder.bytes,
|
"bytes", recorder.bytes,
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
"request_id", RequestIDFromContext(r.Context()),
|
"request_id", RequestIDFromContext(r.Context()),
|
||||||
|
"ip", ip,
|
||||||
"remote_addr", r.RemoteAddr,
|
"remote_addr", r.RemoteAddr,
|
||||||
"user_agent", r.UserAgent(),
|
"user_agent", r.UserAgent(),
|
||||||
)
|
)
|
||||||
|
|||||||
913
backend/libs/services/auth.go
Normal file
@@ -0,0 +1,913 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
usersBucket = []byte("users")
|
||||||
|
userEmailsBucket = []byte("user_emails")
|
||||||
|
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 (
|
||||||
|
UserRoleAdmin = "admin"
|
||||||
|
UserRoleUser = "user"
|
||||||
|
|
||||||
|
UserStatusActive = "active"
|
||||||
|
UserStatusDisabled = "disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
ErrRegistrationClosed = errors.New("registration is closed")
|
||||||
|
ErrInviteInvalid = errors.New("invite is invalid")
|
||||||
|
ErrUserDisabled = errors.New("user is disabled")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
PasswordHash string `json:"passwordHash"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
|
||||||
|
Policy UserPolicy `json:"policy,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserPolicy struct {
|
||||||
|
MaxUploadMB *float64 `json:"maxUploadMb,omitempty"`
|
||||||
|
DailyUploadMB *float64 `json:"dailyUploadMb,omitempty"`
|
||||||
|
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
|
||||||
|
MaxDays *int `json:"maxDays,omitempty"`
|
||||||
|
DailyBoxes *int `json:"dailyBoxes,omitempty"`
|
||||||
|
ActiveBoxes *int `json:"activeBoxes,omitempty"`
|
||||||
|
ShortWindowRequests *int `json:"shortWindowRequests,omitempty"`
|
||||||
|
StorageBackendID *string `json:"storageBackendId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicUser struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Role string
|
||||||
|
Status string
|
||||||
|
StorageQuotaMB *float64
|
||||||
|
Policy UserPolicy
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
TokenHash string `json:"tokenHash"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Invite struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
TokenHash string `json:"tokenHash"`
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
UsedAt *time.Time `json:"usedAt,omitempty"`
|
||||||
|
UsedByUserID string `json:"usedByUserId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collection struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
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
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
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, apiTokensBucket} {
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) BootstrapAvailable() (bool, error) {
|
||||||
|
count := 0
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(usersBucket).ForEach(func(_, _ []byte) error {
|
||||||
|
count++
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count == 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreateBootstrapUser(username, email, password string) (User, error) {
|
||||||
|
available, err := s.BootstrapAvailable()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
return User{}, ErrRegistrationClosed
|
||||||
|
}
|
||||||
|
return s.createUser(username, email, password, UserRoleAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Login(email, password string) (User, string, error) {
|
||||||
|
user, err := s.UserByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return User{}, "", ErrUserDisabled
|
||||||
|
}
|
||||||
|
if !VerifyPasswordHash(user.PasswordHash, password) {
|
||||||
|
return User{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
token := randomID(32)
|
||||||
|
session := Session{
|
||||||
|
ID: randomID(12),
|
||||||
|
UserID: user.ID,
|
||||||
|
TokenHash: tokenHash(token),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
data, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(sessionsBucket).Put([]byte(session.ID), data)
|
||||||
|
})
|
||||||
|
return user, session.ID + "." + token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UserForSession(raw string) (User, Session, error) {
|
||||||
|
sessionID, token, ok := strings.Cut(raw, ".")
|
||||||
|
if !ok || sessionID == "" || token == "" {
|
||||||
|
return User{}, Session{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
var session Session
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(sessionsBucket).Get([]byte(sessionID))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &session)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, Session{}, err
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(tokenHash(token)), []byte(session.TokenHash)) != 1 {
|
||||||
|
return User{}, Session{}, os.ErrPermission
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, Session{}, err
|
||||||
|
}
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return User{}, Session{}, ErrUserDisabled
|
||||||
|
}
|
||||||
|
return user, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Logout(raw string) error {
|
||||||
|
sessionID, _, ok := strings.Cut(raw, ".")
|
||||||
|
if !ok || sessionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(sessionsBucket).Delete([]byte(sessionID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
if role == "" {
|
||||||
|
role = UserRoleUser
|
||||||
|
}
|
||||||
|
if role != UserRoleAdmin && role != UserRoleUser {
|
||||||
|
role = UserRoleUser
|
||||||
|
}
|
||||||
|
if expiresIn <= 0 {
|
||||||
|
expiresIn = 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
token := randomID(32)
|
||||||
|
invite := Invite{
|
||||||
|
ID: randomID(12),
|
||||||
|
Email: email,
|
||||||
|
Role: role,
|
||||||
|
TokenHash: tokenHash(token),
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(expiresIn),
|
||||||
|
}
|
||||||
|
err = s.saveInvite(invite)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
return InviteResult{
|
||||||
|
Invite: invite,
|
||||||
|
Token: token,
|
||||||
|
URL: fmt.Sprintf("%s/invite/%s", s.baseURL, token),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) AcceptInvite(token, username, password string) (User, error) {
|
||||||
|
invite, err := s.InviteByToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
|
||||||
|
return User{}, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
if invite.UserID != "" {
|
||||||
|
user, err = s.UserByID(invite.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if err := s.SetPassword(user.ID, password); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user, _ = s.UserByID(user.ID)
|
||||||
|
} else {
|
||||||
|
user, err = s.createUser(username, invite.Email, password, invite.Role)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
invite.UsedAt = &now
|
||||||
|
invite.UsedByUserID = user.ID
|
||||||
|
if err := s.saveInvite(invite); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) InviteByToken(token string) (Invite, error) {
|
||||||
|
hash := tokenHash(token)
|
||||||
|
var match Invite
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(invitesBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var invite Invite
|
||||||
|
if err := json.Unmarshal(value, &invite); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(hash), []byte(invite.TokenHash)) == 1 {
|
||||||
|
match = invite
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Invite{}, err
|
||||||
|
}
|
||||||
|
if match.ID == "" {
|
||||||
|
return Invite{}, ErrInviteInvalid
|
||||||
|
}
|
||||||
|
return match, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreatePasswordResetInvite(userID, createdBy string) (InviteResult, error) {
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
result, err := s.CreateInvite(user.Email, user.Role, createdBy, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
result.Invite.UserID = user.ID
|
||||||
|
if err := s.saveInvite(result.Invite); err != nil {
|
||||||
|
return InviteResult{}, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ListUsers() ([]User, error) {
|
||||||
|
users := make([]User, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(usersBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var user User
|
||||||
|
if err := json.Unmarshal(value, &user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(users, func(i, j int) bool {
|
||||||
|
return users[i].CreatedAt.After(users[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) DisableUser(userID string, disabled bool) error {
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if disabled {
|
||||||
|
user.Status = UserStatusDisabled
|
||||||
|
} else {
|
||||||
|
user.Status = UserStatusActive
|
||||||
|
}
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetPassword(userID, password string) error {
|
||||||
|
if len(password) < 8 {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.PasswordHash = HashPassword(password)
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error {
|
||||||
|
if quotaMB != nil && *quotaMB <= 0 {
|
||||||
|
return fmt.Errorf("storage quota must be positive")
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.StorageQuotaMB = quotaMB
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetUserPolicy(userID string, policy UserPolicy) error {
|
||||||
|
if err := validateUserPolicy(policy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Policy = policy
|
||||||
|
user.StorageQuotaMB = policy.StorageQuotaMB
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
return s.saveUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
|
||||||
|
user, err := s.UserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
backendID = strings.TrimSpace(backendID)
|
||||||
|
if backendID == "" {
|
||||||
|
user.Policy.StorageBackendID = nil
|
||||||
|
} else {
|
||||||
|
user.Policy.StorageBackendID = &backendID
|
||||||
|
}
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if username == "" {
|
||||||
|
return User{}, fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
email, err := normalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if role != UserRoleAdmin && role != UserRoleUser {
|
||||||
|
return User{}, fmt.Errorf("invalid role")
|
||||||
|
}
|
||||||
|
if status != UserStatusActive && status != UserStatusDisabled {
|
||||||
|
return User{}, fmt.Errorf("invalid status")
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated User
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
users := tx.Bucket(usersBucket)
|
||||||
|
emails := tx.Bucket(userEmailsBucket)
|
||||||
|
data := users.Get([]byte(userID))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
var user User
|
||||||
|
if err := json.Unmarshal(data, &user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing := emails.Get([]byte(email)); existing != nil && string(existing) != user.ID {
|
||||||
|
return fmt.Errorf("email is already registered")
|
||||||
|
}
|
||||||
|
if user.Email != email {
|
||||||
|
if err := emails.Delete([]byte(user.Email)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := emails.Put([]byte(email), []byte(user.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Username = username
|
||||||
|
user.Email = email
|
||||||
|
user.Role = role
|
||||||
|
user.Status = status
|
||||||
|
user.Policy = policy
|
||||||
|
user.StorageQuotaMB = policy.StorageQuotaMB
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
next, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := users.Put([]byte(user.ID), next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated = user
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return updated, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UserByID(id string) (User, error) {
|
||||||
|
var user User
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(usersBucket).Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &user)
|
||||||
|
})
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UserByEmail(email string) (User, error) {
|
||||||
|
email, err := normalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
var userID string
|
||||||
|
err = s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(userEmailsBucket).Get([]byte(email))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
userID = string(data)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return s.UserByID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CreateCollection(userID, name string) (Collection, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return Collection{}, fmt.Errorf("collection name is required")
|
||||||
|
}
|
||||||
|
collection := Collection{
|
||||||
|
ID: randomID(10),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
return collection, s.saveCollection(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ListCollections(userID string) ([]Collection, error) {
|
||||||
|
collections := make([]Collection, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(collectionsBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var collection Collection
|
||||||
|
if err := json.Unmarshal(value, &collection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if collection.UserID == userID {
|
||||||
|
collections = append(collections, collection)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(collections, func(i, j int) bool {
|
||||||
|
return strings.ToLower(collections[i].Name) < strings.ToLower(collections[j].Name)
|
||||||
|
})
|
||||||
|
return collections, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CollectionOwnedBy(collectionID, userID string) bool {
|
||||||
|
if collectionID == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
collection, err := s.CollectionByID(collectionID)
|
||||||
|
return err == nil && collection.UserID == userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) CollectionByID(id string) (Collection, error) {
|
||||||
|
var collection Collection
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(collectionsBucket).Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &collection)
|
||||||
|
})
|
||||||
|
return collection, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) PublicUser(user User) PublicUser {
|
||||||
|
return PublicUser{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Role: user.Role,
|
||||||
|
Status: user.Status,
|
||||||
|
StorageQuotaMB: user.StorageQuotaMB,
|
||||||
|
Policy: user.Policy,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) createUser(username, email, password, role string) (User, error) {
|
||||||
|
username = strings.TrimSpace(username)
|
||||||
|
if username == "" {
|
||||||
|
return User{}, fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
email, err := normalizeEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if len(password) < 8 {
|
||||||
|
return User{}, fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
if role != UserRoleAdmin && role != UserRoleUser {
|
||||||
|
role = UserRoleUser
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
user := User{
|
||||||
|
ID: randomID(12),
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: HashPassword(password),
|
||||||
|
Role: role,
|
||||||
|
Status: UserStatusActive,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
return user, s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
if existing := tx.Bucket(userEmailsBucket).Get([]byte(email)); existing != nil {
|
||||||
|
return fmt.Errorf("email is already registered")
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Bucket(usersBucket).Put([]byte(user.ID), data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(userEmailsBucket).Put([]byte(email), []byte(user.ID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveUser(user User) error {
|
||||||
|
data, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(usersBucket).Put([]byte(user.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveInvite(invite Invite) error {
|
||||||
|
data, err := json.Marshal(invite)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(invitesBucket).Put([]byte(invite.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveCollection(collection Collection) error {
|
||||||
|
data, err := json.Marshal(collection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(collectionsBucket).Put([]byte(collection.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEmail(email string) (string, error) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if email == "" {
|
||||||
|
return "", fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil {
|
||||||
|
return "", fmt.Errorf("email is invalid")
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenHash(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte("warpbox-session:" + token))
|
||||||
|
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 {
|
||||||
|
salt = []byte(randomID(16))[:16]
|
||||||
|
}
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
|
||||||
|
return "argon2id$v=19$m=65536,t=1,p=4$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPasswordHash(encoded, password string) bool {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 5 || parts[0] != "argon2id" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
expected, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
|
||||||
|
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateUserPolicy(policy UserPolicy) error {
|
||||||
|
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 && *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 && *policy.StorageQuotaMB != -1 {
|
||||||
|
return fmt.Errorf("storage quota override must be 0/positive or -1 for unlimited")
|
||||||
|
}
|
||||||
|
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 && *policy.DailyBoxes != -1 {
|
||||||
|
return fmt.Errorf("daily box override must be positive or -1 for unlimited")
|
||||||
|
}
|
||||||
|
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 && *policy.ShortWindowRequests != -1 {
|
||||||
|
return fmt.Errorf("short-window request override must be positive or -1 for unlimited")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
245
backend/libs/services/auth_test.go
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPasswordHashVerification(t *testing.T) {
|
||||||
|
hash := HashPassword("correct-horse")
|
||||||
|
if !VerifyPasswordHash(hash, "correct-horse") {
|
||||||
|
t.Fatalf("VerifyPasswordHash rejected the correct password")
|
||||||
|
}
|
||||||
|
if VerifyPasswordHash(hash, "wrong-password") {
|
||||||
|
t.Fatalf("VerifyPasswordHash accepted the wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapCreatesAdminAndClosesRegistration(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
available, err := auth.BootstrapAvailable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapAvailable returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
t.Fatalf("BootstrapAvailable = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if user.Role != UserRoleAdmin {
|
||||||
|
t.Fatalf("role = %q, want admin", user.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := auth.CreateBootstrapUser("other", "other@example.test", "password123"); err == nil {
|
||||||
|
t.Fatalf("second bootstrap unexpectedly succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSessionAndDisabledUser(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)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Login("daniel@example.test", "wrong"); err == nil {
|
||||||
|
t.Fatalf("Login accepted wrong password")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, token, err := auth.Login("daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
sessionUser, _, err := auth.UserForSession(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserForSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if sessionUser.ID != user.ID {
|
||||||
|
t.Fatalf("session user = %q, want %q", sessionUser.ID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.DisableUser(user.ID, true); err != nil {
|
||||||
|
t.Fatalf("DisableUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.UserForSession(token); err == nil {
|
||||||
|
t.Fatalf("disabled user session still resolved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
admin, err := auth.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := auth.CreateInvite("friend@example.test", UserRoleUser, admin.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
user, err := auth.AcceptInvite(invite.Token, "friend", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
if user.Email != "friend@example.test" {
|
||||||
|
t.Fatalf("email = %q, want friend@example.test", user.Email)
|
||||||
|
}
|
||||||
|
if _, err := auth.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
|
||||||
|
t.Fatalf("AcceptInvite allowed token reuse")
|
||||||
|
}
|
||||||
|
|
||||||
|
reset, err := auth.CreatePasswordResetInvite(user.ID, admin.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePasswordResetInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := auth.AcceptInvite(reset.Token, "", "newpassword123"); err != nil {
|
||||||
|
t.Fatalf("AcceptInvite reset returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Login("friend@example.test", "newpassword123"); err != nil {
|
||||||
|
t.Fatalf("Login with reset password returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
auth, err := NewAuthService(upload.DB(), "http://example.test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewAuthService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
508
backend/libs/services/settings.go
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"warpbox.dev/backend/libs/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
settingsBucket = []byte("settings")
|
||||||
|
usageBucket = []byte("usage")
|
||||||
|
)
|
||||||
|
|
||||||
|
var settingsKey = []byte("upload_policy")
|
||||||
|
|
||||||
|
type UploadPolicySettings struct {
|
||||||
|
AnonymousUploadsEnabled bool `json:"anonymousUploadsEnabled"`
|
||||||
|
AnonymousMaxUploadMB float64 `json:"anonymousMaxUploadMb"`
|
||||||
|
AnonymousDailyUploadMB float64 `json:"anonymousDailyUploadMb"`
|
||||||
|
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
|
||||||
|
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
|
||||||
|
UsageRetentionDays int `json:"usageRetentionDays"`
|
||||||
|
LocalStorageMaxGB float64 `json:"localStorageMaxGb"`
|
||||||
|
AnonymousMaxDays int `json:"anonymousMaxDays"`
|
||||||
|
UserMaxDays int `json:"userMaxDays"`
|
||||||
|
AnonymousDailyBoxes int `json:"anonymousDailyBoxes"`
|
||||||
|
UserDailyBoxes int `json:"userDailyBoxes"`
|
||||||
|
AnonymousActiveBoxes int `json:"anonymousActiveBoxes"`
|
||||||
|
UserActiveBoxes int `json:"userActiveBoxes"`
|
||||||
|
ShortWindowRequests int `json:"shortWindowRequests"`
|
||||||
|
ShortWindowSeconds int `json:"shortWindowSeconds"`
|
||||||
|
AnonymousStorageBackend string `json:"anonymousStorageBackend"`
|
||||||
|
UserStorageBackend string `json:"userStorageBackend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsageRecord struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
SubjectType string `json:"subjectType"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
UploadedBytes int64 `json:"uploadedBytes"`
|
||||||
|
UploadedBoxes int `json:"uploadedBoxes"`
|
||||||
|
RequestCount int `json:"requestCount"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectiveUploadPolicy struct {
|
||||||
|
MaxUploadMB float64
|
||||||
|
DailyUploadMB float64
|
||||||
|
StorageQuotaMB float64
|
||||||
|
MaxDays int
|
||||||
|
DailyBoxes int
|
||||||
|
ActiveBoxes int
|
||||||
|
ShortRequests int
|
||||||
|
ShortWindow time.Duration
|
||||||
|
StorageBackendID string
|
||||||
|
StorageQuotaSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
defaults UploadPolicySettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*SettingsService, error) {
|
||||||
|
service := &SettingsService{
|
||||||
|
db: db,
|
||||||
|
defaults: UploadPolicySettings{
|
||||||
|
AnonymousUploadsEnabled: defaults.AnonymousUploadsEnabled,
|
||||||
|
AnonymousMaxUploadMB: defaults.AnonymousMaxUploadMB,
|
||||||
|
AnonymousDailyUploadMB: defaults.AnonymousDailyUploadMB,
|
||||||
|
UserDailyUploadMB: defaults.UserDailyUploadMB,
|
||||||
|
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
|
||||||
|
UsageRetentionDays: defaults.UsageRetentionDays,
|
||||||
|
LocalStorageMaxGB: defaults.LocalStorageMaxGB,
|
||||||
|
AnonymousMaxDays: defaults.AnonymousMaxDays,
|
||||||
|
UserMaxDays: defaults.UserMaxDays,
|
||||||
|
AnonymousDailyBoxes: defaults.AnonymousDailyBoxes,
|
||||||
|
UserDailyBoxes: defaults.UserDailyBoxes,
|
||||||
|
AnonymousActiveBoxes: defaults.AnonymousActiveBoxes,
|
||||||
|
UserActiveBoxes: defaults.UserActiveBoxes,
|
||||||
|
ShortWindowRequests: defaults.ShortWindowRequests,
|
||||||
|
ShortWindowSeconds: defaults.ShortWindowSeconds,
|
||||||
|
AnonymousStorageBackend: defaults.AnonymousStorageBackend,
|
||||||
|
UserStorageBackend: defaults.UserStorageBackend,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service.defaults = service.withBuiltinDefaultGaps(service.defaults)
|
||||||
|
if err := service.validate(service.defaults); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, bucket := range [][]byte{settingsBucket, usageBucket} {
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) withBuiltinDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
|
||||||
|
if settings.LocalStorageMaxGB <= 0 {
|
||||||
|
settings.LocalStorageMaxGB = 100
|
||||||
|
}
|
||||||
|
if settings.AnonymousMaxDays <= 0 {
|
||||||
|
settings.AnonymousMaxDays = 30
|
||||||
|
}
|
||||||
|
if settings.UserMaxDays <= 0 {
|
||||||
|
settings.UserMaxDays = 90
|
||||||
|
}
|
||||||
|
if settings.AnonymousDailyBoxes <= 0 {
|
||||||
|
settings.AnonymousDailyBoxes = 100
|
||||||
|
}
|
||||||
|
if settings.UserDailyBoxes <= 0 {
|
||||||
|
settings.UserDailyBoxes = 250
|
||||||
|
}
|
||||||
|
if settings.AnonymousActiveBoxes <= 0 {
|
||||||
|
settings.AnonymousActiveBoxes = 500
|
||||||
|
}
|
||||||
|
if settings.UserActiveBoxes <= 0 {
|
||||||
|
settings.UserActiveBoxes = 1000
|
||||||
|
}
|
||||||
|
if settings.ShortWindowRequests <= 0 {
|
||||||
|
settings.ShortWindowRequests = 60
|
||||||
|
}
|
||||||
|
if settings.ShortWindowSeconds <= 0 {
|
||||||
|
settings.ShortWindowSeconds = 60
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(settings.AnonymousStorageBackend) == "" {
|
||||||
|
settings.AnonymousStorageBackend = StorageBackendLocal
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(settings.UserStorageBackend) == "" {
|
||||||
|
settings.UserStorageBackend = StorageBackendLocal
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
||||||
|
settings := s.defaults
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(settingsBucket).Get(settingsKey)
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings = s.withDefaultGaps(settings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return UploadPolicySettings{}, err
|
||||||
|
}
|
||||||
|
if err := s.validate(settings); err != nil {
|
||||||
|
return UploadPolicySettings{}, err
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
|
||||||
|
if settings.AnonymousMaxUploadMB == 0 {
|
||||||
|
settings.AnonymousMaxUploadMB = s.defaults.AnonymousMaxUploadMB
|
||||||
|
}
|
||||||
|
if settings.AnonymousDailyUploadMB == 0 {
|
||||||
|
settings.AnonymousDailyUploadMB = s.defaults.AnonymousDailyUploadMB
|
||||||
|
}
|
||||||
|
if settings.UserDailyUploadMB == 0 {
|
||||||
|
settings.UserDailyUploadMB = s.defaults.UserDailyUploadMB
|
||||||
|
}
|
||||||
|
if settings.DefaultUserStorageMB <= 0 {
|
||||||
|
settings.DefaultUserStorageMB = s.defaults.DefaultUserStorageMB
|
||||||
|
}
|
||||||
|
if settings.UsageRetentionDays <= 0 {
|
||||||
|
settings.UsageRetentionDays = s.defaults.UsageRetentionDays
|
||||||
|
}
|
||||||
|
if settings.LocalStorageMaxGB <= 0 {
|
||||||
|
settings.LocalStorageMaxGB = s.defaults.LocalStorageMaxGB
|
||||||
|
}
|
||||||
|
if settings.AnonymousMaxDays <= 0 {
|
||||||
|
settings.AnonymousMaxDays = s.defaults.AnonymousMaxDays
|
||||||
|
}
|
||||||
|
if settings.UserMaxDays <= 0 {
|
||||||
|
settings.UserMaxDays = s.defaults.UserMaxDays
|
||||||
|
}
|
||||||
|
if settings.AnonymousDailyBoxes <= 0 {
|
||||||
|
settings.AnonymousDailyBoxes = s.defaults.AnonymousDailyBoxes
|
||||||
|
}
|
||||||
|
if settings.UserDailyBoxes <= 0 {
|
||||||
|
settings.UserDailyBoxes = s.defaults.UserDailyBoxes
|
||||||
|
}
|
||||||
|
if settings.AnonymousActiveBoxes <= 0 {
|
||||||
|
settings.AnonymousActiveBoxes = s.defaults.AnonymousActiveBoxes
|
||||||
|
}
|
||||||
|
if settings.UserActiveBoxes <= 0 {
|
||||||
|
settings.UserActiveBoxes = s.defaults.UserActiveBoxes
|
||||||
|
}
|
||||||
|
if settings.ShortWindowRequests <= 0 {
|
||||||
|
settings.ShortWindowRequests = s.defaults.ShortWindowRequests
|
||||||
|
}
|
||||||
|
if settings.ShortWindowSeconds <= 0 {
|
||||||
|
settings.ShortWindowSeconds = s.defaults.ShortWindowSeconds
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(settings.AnonymousStorageBackend) == "" {
|
||||||
|
settings.AnonymousStorageBackend = s.defaults.AnonymousStorageBackend
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(settings.UserStorageBackend) == "" {
|
||||||
|
settings.UserStorageBackend = s.defaults.UserStorageBackend
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
|
||||||
|
if err := s.validate(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(settingsBucket).Put(settingsKey, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(usageBucket).Get([]byte(key))
|
||||||
|
if data == nil {
|
||||||
|
record = UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &record)
|
||||||
|
})
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
|
||||||
|
return s.AddUploadUsage(subjectType, subject, bytes, 0, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) AddUploadUsage(subjectType, subject string, bytes int64, boxes int, now time.Time) error {
|
||||||
|
if bytes <= 0 {
|
||||||
|
bytes = 0
|
||||||
|
}
|
||||||
|
if boxes < 0 {
|
||||||
|
boxes = 0
|
||||||
|
}
|
||||||
|
if bytes == 0 && boxes == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := usageKey(subjectType, subject, now)
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(usageBucket)
|
||||||
|
record := UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
|
||||||
|
data := bucket.Get([]byte(key))
|
||||||
|
if data != nil {
|
||||||
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.UploadedBytes += bytes
|
||||||
|
record.UploadedBoxes += boxes
|
||||||
|
record.UpdatedAt = now.UTC()
|
||||||
|
next, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bucket.Put([]byte(key), next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) EffectivePolicyForAnonymous(settings UploadPolicySettings) EffectiveUploadPolicy {
|
||||||
|
return EffectiveUploadPolicy{
|
||||||
|
MaxUploadMB: settings.AnonymousMaxUploadMB,
|
||||||
|
DailyUploadMB: settings.AnonymousDailyUploadMB,
|
||||||
|
MaxDays: settings.AnonymousMaxDays,
|
||||||
|
DailyBoxes: settings.AnonymousDailyBoxes,
|
||||||
|
ActiveBoxes: settings.AnonymousActiveBoxes,
|
||||||
|
ShortRequests: settings.ShortWindowRequests,
|
||||||
|
ShortWindow: time.Duration(settings.ShortWindowSeconds) * time.Second,
|
||||||
|
StorageBackendID: normalizeBackendID(settings.AnonymousStorageBackend),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) EffectivePolicyForUser(settings UploadPolicySettings, user User) EffectiveUploadPolicy {
|
||||||
|
policy := EffectiveUploadPolicy{
|
||||||
|
MaxUploadMB: 0,
|
||||||
|
DailyUploadMB: settings.UserDailyUploadMB,
|
||||||
|
StorageQuotaMB: settings.DefaultUserStorageMB,
|
||||||
|
MaxDays: settings.UserMaxDays,
|
||||||
|
DailyBoxes: settings.UserDailyBoxes,
|
||||||
|
ActiveBoxes: settings.UserActiveBoxes,
|
||||||
|
ShortRequests: settings.ShortWindowRequests,
|
||||||
|
ShortWindow: time.Duration(settings.ShortWindowSeconds) * time.Second,
|
||||||
|
StorageBackendID: normalizeBackendID(settings.UserStorageBackend),
|
||||||
|
StorageQuotaSet: true,
|
||||||
|
}
|
||||||
|
if user.StorageQuotaMB != nil {
|
||||||
|
policy.StorageQuotaMB = *user.StorageQuotaMB
|
||||||
|
}
|
||||||
|
if user.Policy.MaxUploadMB != nil {
|
||||||
|
policy.MaxUploadMB = *user.Policy.MaxUploadMB
|
||||||
|
}
|
||||||
|
if user.Policy.DailyUploadMB != nil {
|
||||||
|
policy.DailyUploadMB = *user.Policy.DailyUploadMB
|
||||||
|
}
|
||||||
|
if user.Policy.StorageQuotaMB != nil {
|
||||||
|
policy.StorageQuotaMB = *user.Policy.StorageQuotaMB
|
||||||
|
policy.StorageQuotaSet = *user.Policy.StorageQuotaMB > 0
|
||||||
|
}
|
||||||
|
if user.Policy.MaxDays != nil {
|
||||||
|
policy.MaxDays = *user.Policy.MaxDays
|
||||||
|
}
|
||||||
|
if user.Policy.DailyBoxes != nil {
|
||||||
|
policy.DailyBoxes = *user.Policy.DailyBoxes
|
||||||
|
}
|
||||||
|
if user.Policy.ActiveBoxes != nil {
|
||||||
|
policy.ActiveBoxes = *user.Policy.ActiveBoxes
|
||||||
|
}
|
||||||
|
if user.Policy.ShortWindowRequests != nil {
|
||||||
|
policy.ShortRequests = *user.Policy.ShortWindowRequests
|
||||||
|
}
|
||||||
|
if user.Policy.StorageBackendID != nil {
|
||||||
|
policy.StorageBackendID = normalizeBackendID(*user.Policy.StorageBackendID)
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
|
||||||
|
if retentionDays <= 0 {
|
||||||
|
return fmt.Errorf("usage retention days must be positive")
|
||||||
|
}
|
||||||
|
cutoff := now.UTC().AddDate(0, 0, -retentionDays)
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(usageBucket)
|
||||||
|
return bucket.ForEach(func(key, value []byte) error {
|
||||||
|
var record UsageRecord
|
||||||
|
if err := json.Unmarshal(value, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
date, err := time.Parse("2006-01-02", record.Date)
|
||||||
|
if err != nil || date.Before(cutoff) {
|
||||||
|
return bucket.Delete(key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UsageForUser(userID string, now time.Time) (UsageRecord, error) {
|
||||||
|
return s.Usage("user", userID, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, error) {
|
||||||
|
return s.Usage("ip", ip, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
||||||
|
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 && settings.AnonymousDailyUploadMB != -1 || settings.AnonymousDailyUploadMB == 0 {
|
||||||
|
return fmt.Errorf("anonymous daily upload must be positive or -1 for unlimited")
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
if settings.UsageRetentionDays <= 0 {
|
||||||
|
return fmt.Errorf("usage retention days must be positive")
|
||||||
|
}
|
||||||
|
if settings.LocalStorageMaxGB <= 0 {
|
||||||
|
return fmt.Errorf("local storage max must be positive")
|
||||||
|
}
|
||||||
|
if settings.AnonymousMaxDays <= 0 || settings.UserMaxDays <= 0 {
|
||||||
|
return fmt.Errorf("expiration limits must be positive")
|
||||||
|
}
|
||||||
|
if settings.AnonymousDailyBoxes <= 0 || settings.UserDailyBoxes <= 0 {
|
||||||
|
return fmt.Errorf("daily box limits must be positive")
|
||||||
|
}
|
||||||
|
if settings.AnonymousActiveBoxes <= 0 || settings.UserActiveBoxes <= 0 {
|
||||||
|
return fmt.Errorf("active box limits must be positive")
|
||||||
|
}
|
||||||
|
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
|
||||||
|
return fmt.Errorf("short-window rate limits must be positive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMegabytesValue(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)
|
||||||
|
parsed, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if parsed <= 0 {
|
||||||
|
return 0, fmt.Errorf("megabyte value must be positive")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GigabytesToBytes(value float64) int64 {
|
||||||
|
return int64(value * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageKey(subjectType, subject string, now time.Time) string {
|
||||||
|
return subjectType + ":" + subject + ":" + usageDate(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageDate(now time.Time) string {
|
||||||
|
return now.UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBackendID(id string) string {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return StorageBackendLocal
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
235
backend/libs/services/settings_test.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsLoadDefaultsAndOverrides(t *testing.T) {
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
policy, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !policy.AnonymousUploadsEnabled || policy.AnonymousMaxUploadMB != 512 {
|
||||||
|
t.Fatalf("default policy = %+v", policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.AnonymousUploadsEnabled = false
|
||||||
|
policy.UserDailyUploadMB = 123
|
||||||
|
if err := settings.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
next, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
if next.AnonymousUploadsEnabled || next.UserDailyUploadMB != 123 {
|
||||||
|
t.Fatalf("override policy = %+v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsUseNewEnvDefaultsUntilSaved(t *testing.T) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
defer upload.Close()
|
||||||
|
|
||||||
|
first, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 111,
|
||||||
|
AnonymousDailyUploadMB: 222,
|
||||||
|
UserDailyUploadMB: 333,
|
||||||
|
DefaultUserStorageMB: 444,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService first returned error: %v", err)
|
||||||
|
}
|
||||||
|
firstPolicy, err := first.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy first returned error: %v", err)
|
||||||
|
}
|
||||||
|
if firstPolicy.AnonymousMaxUploadMB != 111 {
|
||||||
|
t.Fatalf("first AnonymousMaxUploadMB = %v, want 111", firstPolicy.AnonymousMaxUploadMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 555,
|
||||||
|
AnonymousDailyUploadMB: 666,
|
||||||
|
UserDailyUploadMB: 777,
|
||||||
|
DefaultUserStorageMB: 888,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService second returned error: %v", err)
|
||||||
|
}
|
||||||
|
secondPolicy, err := second.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy second returned error: %v", err)
|
||||||
|
}
|
||||||
|
if secondPolicy.AnonymousMaxUploadMB != 555 {
|
||||||
|
t.Fatalf("second AnonymousMaxUploadMB = %v, want 555", secondPolicy.AnonymousMaxUploadMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := second.UpdateUploadPolicy(secondPolicy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
third, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 999,
|
||||||
|
AnonymousDailyUploadMB: 999,
|
||||||
|
UserDailyUploadMB: 999,
|
||||||
|
DefaultUserStorageMB: 999,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService third returned error: %v", err)
|
||||||
|
}
|
||||||
|
thirdPolicy, err := third.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy third returned error: %v", err)
|
||||||
|
}
|
||||||
|
if thirdPolicy.AnonymousMaxUploadMB != 555 {
|
||||||
|
t.Fatalf("third AnonymousMaxUploadMB = %v, want persisted 555", thirdPolicy.AnonymousMaxUploadMB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsRejectInvalidMegabytes(t *testing.T) {
|
||||||
|
if _, err := ParseMegabytesValue("0"); err == nil {
|
||||||
|
t.Fatalf("ParseMegabytesValue accepted zero")
|
||||||
|
}
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
policy, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy.DefaultUserStorageMB = -1
|
||||||
|
if err := settings.UpdateUploadPolicy(policy); err == nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy accepted negative storage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err := settings.AddUsage("ip", "127.0.0.1", 1024, now); err != nil {
|
||||||
|
t.Fatalf("AddUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := settings.AddUsage("ip", "127.0.0.1", 2048, now); err != nil {
|
||||||
|
t.Fatalf("AddUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
usage, err := settings.UsageForIP("127.0.0.1", now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageForIP returned error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.UploadedBytes != 3072 {
|
||||||
|
t.Fatalf("UploadedBytes = %d, want 3072", usage.UploadedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := settings.CleanupUsage(now.AddDate(0, 0, 31), 30); err != nil {
|
||||||
|
t.Fatalf("CleanupUsage returned error: %v", err)
|
||||||
|
}
|
||||||
|
usage, err = settings.UsageForIP("127.0.0.1", now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UsageForIP returned error: %v", err)
|
||||||
|
}
|
||||||
|
if usage.UploadedBytes != 0 {
|
||||||
|
t.Fatalf("UploadedBytes after cleanup = %d, want 0", usage.UploadedBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveUserPolicyUsesOverridesAndInheritance(t *testing.T) {
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
policy, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy.UserDailyUploadMB = 100
|
||||||
|
policy.DefaultUserStorageMB = 200
|
||||||
|
policy.UserMaxDays = 30
|
||||||
|
policy.UserDailyBoxes = 40
|
||||||
|
policy.UserActiveBoxes = 50
|
||||||
|
policy.UserStorageBackend = "local"
|
||||||
|
|
||||||
|
overrideDaily := 300.0
|
||||||
|
overrideQuota := 0.0
|
||||||
|
overrideDays := 12
|
||||||
|
overrideBackend := "bucket-1"
|
||||||
|
user := User{
|
||||||
|
ID: "user-1",
|
||||||
|
Policy: UserPolicy{
|
||||||
|
DailyUploadMB: &overrideDaily,
|
||||||
|
StorageQuotaMB: &overrideQuota,
|
||||||
|
MaxDays: &overrideDays,
|
||||||
|
StorageBackendID: &overrideBackend,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
effective := settings.EffectivePolicyForUser(policy, user)
|
||||||
|
if effective.DailyUploadMB != overrideDaily || effective.MaxDays != overrideDays || effective.StorageBackendID != overrideBackend {
|
||||||
|
t.Fatalf("effective policy did not use overrides: %+v", effective)
|
||||||
|
}
|
||||||
|
if effective.StorageQuotaSet {
|
||||||
|
t.Fatalf("zero storage quota override should mean unlimited: %+v", effective)
|
||||||
|
}
|
||||||
|
if effective.DailyBoxes != policy.UserDailyBoxes || effective.ActiveBoxes != policy.UserActiveBoxes {
|
||||||
|
t.Fatalf("effective policy did not inherit box caps: %+v", effective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestSettingsService(t *testing.T) *SettingsService {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
settings, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
|
||||||
|
AnonymousUploadsEnabled: true,
|
||||||
|
AnonymousMaxUploadMB: 512,
|
||||||
|
AnonymousDailyUploadMB: 2048,
|
||||||
|
UserDailyUploadMB: 8192,
|
||||||
|
DefaultUserStorageMB: 51200,
|
||||||
|
UsageRetentionDays: 30,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
521
backend/libs/services/storage.go
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var storageBackendsBucket = []byte("storage_backends")
|
||||||
|
var storageBackendTestStatusBucket = []byte("storage_backend_test_status")
|
||||||
|
|
||||||
|
const (
|
||||||
|
StorageBackendLocal = "local"
|
||||||
|
StorageBackendS3 = "s3"
|
||||||
|
StorageBackendSFTP = "sftp"
|
||||||
|
StorageBackendSMB = "smb"
|
||||||
|
StorageBackendWebDAV = "webdav"
|
||||||
|
|
||||||
|
StorageProviderS3 = "s3"
|
||||||
|
StorageProviderContabo = "contabo"
|
||||||
|
StorageProviderSFTP = "sftp"
|
||||||
|
StorageProviderSMB = "smb"
|
||||||
|
StorageProviderWebDAV = "webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StorageObject struct {
|
||||||
|
Key string
|
||||||
|
Size int64
|
||||||
|
ContentType string
|
||||||
|
ModTime time.Time
|
||||||
|
Body io.ReadCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageBackend interface {
|
||||||
|
ID() string
|
||||||
|
Type() string
|
||||||
|
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
|
||||||
|
Get(ctx context.Context, key string) (StorageObject, error)
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
DeletePrefix(ctx context.Context, prefix string) error
|
||||||
|
Usage(ctx context.Context) (int64, error)
|
||||||
|
Test(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageBackendConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Provider string `json:"provider,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
LocalPath string `json:"localPath,omitempty"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Bucket string `json:"bucket,omitempty"`
|
||||||
|
AccessKey string `json:"accessKey,omitempty"`
|
||||||
|
SecretKey string `json:"secretKey,omitempty"`
|
||||||
|
UseSSL bool `json:"useSsl,omitempty"`
|
||||||
|
PathStyle bool `json:"pathStyle,omitempty"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
Port int `json:"port,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
PrivateKey string `json:"privateKey,omitempty"`
|
||||||
|
HostKey string `json:"hostKey,omitempty"`
|
||||||
|
RemotePath string `json:"remotePath,omitempty"`
|
||||||
|
Share string `json:"share,omitempty"`
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
|
||||||
|
LastTestError string `json:"lastTestError,omitempty"`
|
||||||
|
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageBackendView struct {
|
||||||
|
Config StorageBackendConfig
|
||||||
|
UsageBytes int64
|
||||||
|
UsageLabel string
|
||||||
|
InUse bool
|
||||||
|
InUseReason string
|
||||||
|
SpeedTests []StorageSpeedTest
|
||||||
|
CanSpeedTest bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
localFilesDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) {
|
||||||
|
filesDir := filepath.Join(dataDir, "files")
|
||||||
|
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
service := &StorageService{db: db, localFilesDir: filesDir}
|
||||||
|
err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(storageBackendsBucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(storageBackendTestStatusBucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := tx.CreateBucketIfNotExists(storageSpeedTestsBucket)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return service, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) LocalFilesDir() string {
|
||||||
|
return s.localFilesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) Backend(id string) (StorageBackend, error) {
|
||||||
|
cfg, err := s.BackendConfig(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, fmt.Errorf("storage backend is disabled")
|
||||||
|
}
|
||||||
|
return s.backendFromConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) BackendForMaintenance(id string) (StorageBackend, error) {
|
||||||
|
cfg, err := s.BackendConfig(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.backendFromConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || id == StorageBackendLocal {
|
||||||
|
cfg := s.localConfig()
|
||||||
|
s.applyStoredTestStatus(&cfg)
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
var cfg StorageBackendConfig
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(storageBackendsBucket).Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &cfg)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) ListBackendConfigs() ([]StorageBackendConfig, error) {
|
||||||
|
configs := []StorageBackendConfig{s.localConfig()}
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(storageBackendsBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var cfg StorageBackendConfig
|
||||||
|
if err := json.Unmarshal(value, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configs = append(configs, cfg)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(configs, func(i, j int) bool {
|
||||||
|
if configs[i].ID == StorageBackendLocal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if configs[j].ID == StorageBackendLocal {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.ToLower(configs[i].Name) < strings.ToLower(configs[j].Name)
|
||||||
|
})
|
||||||
|
return configs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||||
|
return s.CreateBackend(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) CreateBackend(input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||||
|
input.ID = randomID(10)
|
||||||
|
input.Provider = normalizeStorageProvider(input.Provider)
|
||||||
|
input.Type = storageTypeForProvider(input.Provider)
|
||||||
|
if err := normalizeStorageBackendConfig(&input, true); err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
input.Enabled = true
|
||||||
|
input.CreatedAt = now
|
||||||
|
input.UpdatedAt = now
|
||||||
|
if err := s.SaveBackendConfig(input); err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||||
|
return s.UpdateBackend(id, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) UpdateBackend(id string, input StorageBackendConfig) (StorageBackendConfig, error) {
|
||||||
|
current, err := s.BackendConfig(id)
|
||||||
|
if err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
if current.ID == StorageBackendLocal {
|
||||||
|
return StorageBackendConfig{}, fmt.Errorf("local storage cannot be edited")
|
||||||
|
}
|
||||||
|
current.Provider = canonicalStorageProvider(current)
|
||||||
|
current.Type = storageTypeForProvider(current.Provider)
|
||||||
|
|
||||||
|
input.ID = current.ID
|
||||||
|
requestedProvider := normalizeStorageProvider(input.Provider)
|
||||||
|
requestedType := storageTypeForProvider(requestedProvider)
|
||||||
|
if input.Type != "" && input.Type != requestedType {
|
||||||
|
return StorageBackendConfig{}, fmt.Errorf("storage type cannot be changed after creation")
|
||||||
|
}
|
||||||
|
input.Provider = requestedProvider
|
||||||
|
input.Type = requestedType
|
||||||
|
if input.Provider != current.Provider || input.Type != current.Type {
|
||||||
|
return StorageBackendConfig{}, fmt.Errorf("storage provider cannot be changed after creation")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.SecretKey) == "" {
|
||||||
|
input.SecretKey = current.SecretKey
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.Password) == "" {
|
||||||
|
input.Password = current.Password
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.PrivateKey) == "" {
|
||||||
|
input.PrivateKey = current.PrivateKey
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(input.HostKey) == "" {
|
||||||
|
input.HostKey = current.HostKey
|
||||||
|
}
|
||||||
|
input.Enabled = current.Enabled
|
||||||
|
input.CreatedAt = current.CreatedAt
|
||||||
|
input.LastTestedAt = current.LastTestedAt
|
||||||
|
input.LastTestError = current.LastTestError
|
||||||
|
input.LastTestSuccess = current.LastTestSuccess
|
||||||
|
if err := normalizeStorageBackendConfig(&input, false); err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
if err := s.SaveBackendConfig(input); err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
return input, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStorageBackendConfig(input *StorageBackendConfig, creating bool) error {
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.Provider = normalizeStorageProvider(input.Provider)
|
||||||
|
if input.Provider == StorageProviderSFTP {
|
||||||
|
input.Type = StorageBackendSFTP
|
||||||
|
input.Host = strings.TrimSpace(input.Host)
|
||||||
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
|
input.Password = strings.TrimSpace(input.Password)
|
||||||
|
input.PrivateKey = strings.TrimSpace(input.PrivateKey)
|
||||||
|
input.HostKey = strings.TrimSpace(input.HostKey)
|
||||||
|
input.RemotePath = cleanRemoteRoot(input.RemotePath)
|
||||||
|
if input.Port <= 0 {
|
||||||
|
input.Port = 22
|
||||||
|
}
|
||||||
|
if input.Name == "" {
|
||||||
|
input.Name = input.Host
|
||||||
|
}
|
||||||
|
if input.Name == "" || input.Host == "" || input.Username == "" || (input.Password == "" && input.PrivateKey == "") {
|
||||||
|
return fmt.Errorf("name, host, username, and password or private key are required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if input.Provider == StorageProviderSMB {
|
||||||
|
input.Type = StorageBackendSMB
|
||||||
|
input.Host = strings.TrimSpace(input.Host)
|
||||||
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
|
input.Password = strings.TrimSpace(input.Password)
|
||||||
|
input.Share = strings.Trim(strings.TrimSpace(input.Share), `/\`)
|
||||||
|
input.Domain = strings.TrimSpace(input.Domain)
|
||||||
|
input.RemotePath = cleanRemoteRoot(input.RemotePath)
|
||||||
|
if input.Port <= 0 {
|
||||||
|
input.Port = 445
|
||||||
|
}
|
||||||
|
if input.Name == "" {
|
||||||
|
input.Name = input.Share
|
||||||
|
}
|
||||||
|
if input.Name == "" || input.Host == "" || input.Share == "" || input.Username == "" || input.Password == "" {
|
||||||
|
return fmt.Errorf("name, host, share, username, and password are required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if input.Provider == StorageProviderWebDAV {
|
||||||
|
input.Type = StorageBackendWebDAV
|
||||||
|
input.Endpoint = strings.TrimSpace(input.Endpoint)
|
||||||
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
|
input.Password = strings.TrimSpace(input.Password)
|
||||||
|
input.RemotePath = cleanRemoteRoot(input.RemotePath)
|
||||||
|
if input.Name == "" {
|
||||||
|
input.Name = input.Endpoint
|
||||||
|
}
|
||||||
|
if input.Name == "" || input.Endpoint == "" {
|
||||||
|
return fmt.Errorf("name and WebDAV URL are required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
input.Type = StorageBackendS3
|
||||||
|
if input.Provider == StorageProviderContabo {
|
||||||
|
input.UseSSL = true
|
||||||
|
input.PathStyle = true
|
||||||
|
}
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
input.Endpoint = strings.TrimSpace(input.Endpoint)
|
||||||
|
input.Region = strings.TrimSpace(input.Region)
|
||||||
|
input.Bucket = strings.TrimSpace(input.Bucket)
|
||||||
|
input.AccessKey = strings.TrimSpace(input.AccessKey)
|
||||||
|
input.SecretKey = strings.TrimSpace(input.SecretKey)
|
||||||
|
if input.Name == "" {
|
||||||
|
input.Name = input.Bucket
|
||||||
|
}
|
||||||
|
if input.Name == "" || input.Endpoint == "" || input.Bucket == "" || input.AccessKey == "" || input.SecretKey == "" {
|
||||||
|
return fmt.Errorf("name, endpoint, bucket, access key, and secret key are required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) SaveBackendConfig(cfg StorageBackendConfig) error {
|
||||||
|
if cfg.ID == "" || cfg.ID == StorageBackendLocal {
|
||||||
|
return fmt.Errorf("invalid storage backend id")
|
||||||
|
}
|
||||||
|
cfg.UpdatedAt = time.Now().UTC()
|
||||||
|
data, err := json.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(storageBackendsBucket).Put([]byte(cfg.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) DeleteBackend(id string, inUse bool) error {
|
||||||
|
if id == "" || id == StorageBackendLocal {
|
||||||
|
return fmt.Errorf("local storage cannot be deleted")
|
||||||
|
}
|
||||||
|
if inUse {
|
||||||
|
return fmt.Errorf("storage backend is in use")
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(storageBackendsBucket).Delete([]byte(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) TestBackend(id string) (StorageBackendConfig, error) {
|
||||||
|
cfg, err := s.BackendConfig(id)
|
||||||
|
if err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
backend, err := s.backendFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return StorageBackendConfig{}, err
|
||||||
|
}
|
||||||
|
err = backend.Test(context.Background())
|
||||||
|
cfg.LastTestedAt = time.Now().UTC()
|
||||||
|
cfg.LastTestError = ""
|
||||||
|
cfg.LastTestSuccess = err == nil
|
||||||
|
if err != nil {
|
||||||
|
cfg.LastTestError = err.Error()
|
||||||
|
}
|
||||||
|
if cfg.ID != StorageBackendLocal {
|
||||||
|
_ = s.SaveBackendConfig(cfg)
|
||||||
|
} else {
|
||||||
|
_ = s.saveBackendTestStatus(cfg)
|
||||||
|
}
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) applyStoredTestStatus(cfg *StorageBackendConfig) {
|
||||||
|
_ = s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(storageBackendTestStatusBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data := bucket.Get([]byte(cfg.ID))
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var status struct {
|
||||||
|
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
|
||||||
|
LastTestError string `json:"lastTestError,omitempty"`
|
||||||
|
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &status); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg.LastTestedAt = status.LastTestedAt
|
||||||
|
cfg.LastTestError = status.LastTestError
|
||||||
|
cfg.LastTestSuccess = status.LastTestSuccess
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) saveBackendTestStatus(cfg StorageBackendConfig) error {
|
||||||
|
status := struct {
|
||||||
|
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
|
||||||
|
LastTestError string `json:"lastTestError,omitempty"`
|
||||||
|
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
|
||||||
|
}{
|
||||||
|
LastTestedAt: cfg.LastTestedAt,
|
||||||
|
LastTestError: cfg.LastTestError,
|
||||||
|
LastTestSuccess: cfg.LastTestSuccess,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(status)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(storageBackendTestStatusBucket).Put([]byte(cfg.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBackend, error) {
|
||||||
|
switch cfg.Type {
|
||||||
|
case StorageBackendLocal:
|
||||||
|
return localStorageBackend{id: cfg.ID, root: cfg.LocalPath}, nil
|
||||||
|
case StorageBackendS3:
|
||||||
|
return newS3StorageBackend(cfg)
|
||||||
|
case StorageBackendSFTP:
|
||||||
|
return sftpStorageBackend{cfg: cfg}, nil
|
||||||
|
case StorageBackendSMB:
|
||||||
|
return smbStorageBackend{cfg: cfg}, nil
|
||||||
|
case StorageBackendWebDAV:
|
||||||
|
return newWebDAVStorageBackend(cfg), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported storage backend type %q", cfg.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) localConfig() StorageBackendConfig {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return StorageBackendConfig{
|
||||||
|
ID: StorageBackendLocal,
|
||||||
|
Name: "Local files",
|
||||||
|
Type: StorageBackendLocal,
|
||||||
|
Provider: StorageBackendLocal,
|
||||||
|
Enabled: true,
|
||||||
|
LocalPath: s.localFilesDir,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStorageProvider(provider string) string {
|
||||||
|
switch strings.TrimSpace(provider) {
|
||||||
|
case StorageProviderContabo:
|
||||||
|
return StorageProviderContabo
|
||||||
|
case StorageProviderSFTP:
|
||||||
|
return StorageProviderSFTP
|
||||||
|
case StorageProviderSMB:
|
||||||
|
return StorageProviderSMB
|
||||||
|
case StorageProviderWebDAV:
|
||||||
|
return StorageProviderWebDAV
|
||||||
|
default:
|
||||||
|
return StorageProviderS3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalStorageProvider(cfg StorageBackendConfig) string {
|
||||||
|
if cfg.Provider != "" && cfg.Provider != StorageBackendLocal {
|
||||||
|
return normalizeStorageProvider(cfg.Provider)
|
||||||
|
}
|
||||||
|
switch cfg.Type {
|
||||||
|
case StorageBackendSFTP:
|
||||||
|
return StorageProviderSFTP
|
||||||
|
case StorageBackendSMB:
|
||||||
|
return StorageProviderSMB
|
||||||
|
case StorageBackendWebDAV:
|
||||||
|
return StorageProviderWebDAV
|
||||||
|
default:
|
||||||
|
return StorageProviderS3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storageTypeForProvider(provider string) string {
|
||||||
|
switch normalizeStorageProvider(provider) {
|
||||||
|
case StorageProviderSFTP:
|
||||||
|
return StorageBackendSFTP
|
||||||
|
case StorageProviderSMB:
|
||||||
|
return StorageBackendSMB
|
||||||
|
case StorageProviderWebDAV:
|
||||||
|
return StorageBackendWebDAV
|
||||||
|
default:
|
||||||
|
return StorageBackendS3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanObjectKey(key string) string {
|
||||||
|
return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanRemoteRoot(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(strings.ReplaceAll(value, "\\", "/"))
|
||||||
|
if cleaned == "/" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(cleaned, "/")
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -12,8 +14,10 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,54 +34,70 @@ type UploadService struct {
|
|||||||
filesDir string
|
filesDir string
|
||||||
db *bbolt.DB
|
db *bbolt.DB
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
storage *StorageService
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadOptions struct {
|
type UploadOptions struct {
|
||||||
MaxDays int
|
MaxDays int
|
||||||
|
ExpiresInMinutes int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
Password string
|
Password string
|
||||||
ObfuscateMetadata bool
|
ObfuscateMetadata bool
|
||||||
|
OwnerID string
|
||||||
|
CollectionID string
|
||||||
|
SkipSizeLimit bool
|
||||||
|
CreatorIP string
|
||||||
|
StorageBackendID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Box struct {
|
type Box struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
OwnerID string `json:"ownerId,omitempty"`
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
CollectionID string `json:"collectionId,omitempty"`
|
||||||
MaxDownloads int `json:"maxDownloads"`
|
Title string `json:"title,omitempty"`
|
||||||
DownloadCount int `json:"downloadCount"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
PasswordHash string `json:"passwordHash,omitempty"`
|
MaxDownloads int `json:"maxDownloads"`
|
||||||
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
|
DownloadCount int `json:"downloadCount"`
|
||||||
Obfuscate bool `json:"obfuscate"`
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
Files []File `json:"files"`
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
|
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
|
||||||
|
Obfuscate bool `json:"obfuscate"`
|
||||||
|
CreatorIP string `json:"creatorIp,omitempty"`
|
||||||
|
StorageBackendID string `json:"storageBackendId,omitempty"`
|
||||||
|
Files []File `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
StoredName string `json:"storedName"`
|
StoredName string `json:"storedName"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
PreviewKind string `json:"previewKind"`
|
PreviewKind string `json:"previewKind"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploadedAt"`
|
ObjectKey string `json:"objectKey,omitempty"`
|
||||||
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||||
|
UploadedAt time.Time `json:"uploadedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
BoxID string `json:"boxId"`
|
BoxID string `json:"boxId"`
|
||||||
BoxURL string `json:"boxUrl"`
|
BoxURL string `json:"boxUrl"`
|
||||||
ZipURL string `json:"zipUrl"`
|
ZipURL string `json:"zipUrl"`
|
||||||
ManageURL string `json:"manageUrl"`
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
DeleteURL string `json:"deleteUrl"`
|
ManageURL string `json:"manageUrl"`
|
||||||
ExpiresAt string `json:"expiresAt"`
|
DeleteURL string `json:"deleteUrl"`
|
||||||
Files []ResultFile `json:"files"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
Files []ResultFile `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResultFile struct {
|
type ResultFile struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminStats struct {
|
type AdminStats struct {
|
||||||
@@ -93,6 +113,7 @@ type AdminStats struct {
|
|||||||
|
|
||||||
type AdminBox struct {
|
type AdminBox struct {
|
||||||
ID string
|
ID string
|
||||||
|
OwnerID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
FileCount int
|
FileCount int
|
||||||
@@ -104,12 +125,15 @@ type AdminBox struct {
|
|||||||
Expired bool
|
Expired bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserBox struct {
|
||||||
|
Box Box
|
||||||
|
CollectionName string
|
||||||
|
TotalSizeLabel string
|
||||||
|
}
|
||||||
|
|
||||||
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
|
||||||
filesDir := filepath.Join(dataDir, "files")
|
filesDir := filepath.Join(dataDir, "files")
|
||||||
dbDir := filepath.Join(dataDir, "db")
|
dbDir := filepath.Join(dataDir, "db")
|
||||||
if err := os.MkdirAll(filesDir, 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -126,6 +150,11 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
storage, err := NewStorageService(db, dataDir)
|
||||||
|
if err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return &UploadService{
|
return &UploadService{
|
||||||
maxUploadSize: maxUploadSize,
|
maxUploadSize: maxUploadSize,
|
||||||
@@ -134,6 +163,7 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
|
|||||||
filesDir: filesDir,
|
filesDir: filesDir,
|
||||||
db: db,
|
db: db,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
storage: storage,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +171,10 @@ func (s *UploadService) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) DB() *bbolt.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) MaxUploadSize() int64 {
|
func (s *UploadService) MaxUploadSize() int64 {
|
||||||
return s.maxUploadSize
|
return s.maxUploadSize
|
||||||
}
|
}
|
||||||
@@ -149,6 +183,10 @@ func (s *UploadService) MaxUploadSizeLabel() string {
|
|||||||
return helpers.FormatBytes(s.maxUploadSize)
|
return helpers.FormatBytes(s.maxUploadSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) Storage() *StorageService {
|
||||||
|
return s.storage
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) ValidateSize(size int64) error {
|
func (s *UploadService) ValidateSize(size int64) error {
|
||||||
if size > s.maxUploadSize {
|
if size > s.maxUploadSize {
|
||||||
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
|
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
|
||||||
@@ -160,17 +198,34 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
||||||
}
|
}
|
||||||
if opts.MaxDays <= 0 {
|
now := time.Now().UTC()
|
||||||
opts.MaxDays = 7
|
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{
|
box := Box{
|
||||||
ID: randomID(10),
|
ID: randomID(10),
|
||||||
CreatedAt: time.Now().UTC(),
|
OwnerID: strings.TrimSpace(opts.OwnerID),
|
||||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
CollectionID: strings.TrimSpace(opts.CollectionID),
|
||||||
MaxDownloads: opts.MaxDownloads,
|
CreatorIP: strings.TrimSpace(opts.CreatorIP),
|
||||||
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
StorageBackendID: normalizeBackendID(opts.StorageBackendID),
|
||||||
Files: make([]File, 0, len(files)),
|
CreatedAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
MaxDownloads: opts.MaxDownloads,
|
||||||
|
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
||||||
|
Files: make([]File, 0, len(files)),
|
||||||
}
|
}
|
||||||
deleteToken := randomID(32)
|
deleteToken := randomID(32)
|
||||||
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
||||||
@@ -180,46 +235,10 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
box.PasswordHash = hash
|
box.PasswordHash = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
boxDir := filepath.Join(s.filesDir, box.ID)
|
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
||||||
if err := os.MkdirAll(boxDir, 0o755); err != nil {
|
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, header := range files {
|
|
||||||
if err := s.ValidateSize(header.Size); err != nil {
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := header.Open()
|
|
||||||
if err != nil {
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fileID := randomID(8)
|
|
||||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
|
||||||
storedPath := filepath.Join(boxDir, storedName)
|
|
||||||
contentType := header.Header.Get("Content-Type")
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil {
|
|
||||||
file.Close()
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
box.Files = append(box.Files, File{
|
|
||||||
ID: fileID,
|
|
||||||
Name: filepath.Base(header.Filename),
|
|
||||||
StoredName: storedName,
|
|
||||||
Size: header.Size,
|
|
||||||
ContentType: contentType,
|
|
||||||
PreviewKind: previewKind(contentType),
|
|
||||||
UploadedAt: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.SaveBox(box); err != nil {
|
if err := s.SaveBox(box); err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
@@ -235,6 +254,93 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
return s.resultForBox(box, deleteToken), nil
|
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 err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := s.maxUploadSize
|
||||||
|
if opts.SkipSizeLimit {
|
||||||
|
maxSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := header.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := randomID(8)
|
||||||
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
|
contentType := header.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, _ := file.Read(buffer)
|
||||||
|
contentType = http.DetectContentType(buffer[:n])
|
||||||
|
if seeker, ok := file.(io.Seeker); ok {
|
||||||
|
_, _ = seeker.Seek(0, io.SeekStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
||||||
|
file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
box.Files = append(box.Files, File{
|
||||||
|
ID: fileID,
|
||||||
|
Name: filepath.Base(header.Filename),
|
||||||
|
StoredName: storedName,
|
||||||
|
Size: header.Size,
|
||||||
|
ContentType: contentType,
|
||||||
|
PreviewKind: previewKind(contentType),
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
UploadedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) GetBox(id string) (Box, error) {
|
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||||
var box Box
|
var box Box
|
||||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
@@ -269,6 +375,29 @@ func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
|
|||||||
return boxes, err
|
return boxes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ActiveBoxCountForUser(userID string) (int, error) {
|
||||||
|
return s.activeBoxCount(func(box Box) bool { return box.OwnerID == userID })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ActiveBoxCountForIP(ip string) (int, error) {
|
||||||
|
return s.activeBoxCount(func(box Box) bool { return box.OwnerID == "" && box.CreatorIP == ip })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) activeBoxCount(match func(Box) bool) (int, error) {
|
||||||
|
boxes, err := s.ListBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
count := 0
|
||||||
|
for _, box := range boxes {
|
||||||
|
if match(box) && box.ExpiresAt.After(now) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) AdminStats() (AdminStats, error) {
|
func (s *UploadService) AdminStats() (AdminStats, error) {
|
||||||
boxes, err := s.ListBoxes(0)
|
boxes, err := s.ListBoxes(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -314,6 +443,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
|||||||
}
|
}
|
||||||
rows = append(rows, AdminBox{
|
rows = append(rows, AdminBox{
|
||||||
ID: box.ID,
|
ID: box.ID,
|
||||||
|
OwnerID: box.OwnerID,
|
||||||
CreatedAt: box.CreatedAt,
|
CreatedAt: box.CreatedAt,
|
||||||
ExpiresAt: box.ExpiresAt,
|
ExpiresAt: box.ExpiresAt,
|
||||||
FileCount: len(box.Files),
|
FileCount: len(box.Files),
|
||||||
@@ -328,10 +458,123 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
|
||||||
|
boxes, err := s.ListBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]UserBox, 0)
|
||||||
|
for _, box := range boxes {
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
for _, file := range box.Files {
|
||||||
|
size += file.Size
|
||||||
|
}
|
||||||
|
rows = append(rows, UserBox{
|
||||||
|
Box: box,
|
||||||
|
CollectionName: collectionNames[box.CollectionID],
|
||||||
|
TotalSizeLabel: helpers.FormatBytes(size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
|
||||||
|
})
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
|
||||||
|
return s.userStorageUsed(userID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
|
||||||
|
return s.userStorageUsed(userID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
|
||||||
|
boxes, err := s.ListBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, box := range boxes {
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if activeOnly && !box.ExpiresAt.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, file := range box.Files {
|
||||||
|
total += file.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
box.Title = strings.TrimSpace(title)
|
||||||
|
return s.SaveBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
box.CollectionID = strings.TrimSpace(collectionID)
|
||||||
|
return s.SaveBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if box.OwnerID != userID {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
return s.DeleteBoxWithSource(boxID, "user-delete")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) DeleteBox(boxID string) error {
|
func (s *UploadService) DeleteBox(boxID string) error {
|
||||||
return s.DeleteBoxWithSource(boxID, "admin")
|
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 {
|
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
|
||||||
box, err := s.GetBox(boxID)
|
box, err := s.GetBox(boxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,18 +587,106 @@ func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
||||||
|
box, _ := s.GetBox(boxID)
|
||||||
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
|
if box.ID != "" {
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID)
|
s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID)
|
||||||
return nil
|
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) {
|
func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
|
||||||
for _, file := range box.Files {
|
for _, file := range box.Files {
|
||||||
if file.ID == fileID {
|
if file.ID == fileID {
|
||||||
@@ -380,6 +711,56 @@ func (s *UploadService) BoxMetadataPath(box Box) string {
|
|||||||
return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
|
return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) BoxStorageBackendID(box Box) string {
|
||||||
|
return normalizeBackendID(box.StorageBackendID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) FileObjectKey(box Box, file File) string {
|
||||||
|
if file.ObjectKey != "" {
|
||||||
|
return file.ObjectKey
|
||||||
|
}
|
||||||
|
return boxObjectKey(box.ID, file.StoredName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
|
||||||
|
if file.ThumbnailObjectKey != "" {
|
||||||
|
return file.ThumbnailObjectKey
|
||||||
|
}
|
||||||
|
if file.Thumbnail == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return boxObjectKey(box.ID, file.Thumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return StorageObject{}, err
|
||||||
|
}
|
||||||
|
return backend.Get(ctx, s.FileObjectKey(box, file))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
key := s.ThumbnailObjectKey(box, file)
|
||||||
|
if key == "" {
|
||||||
|
return StorageObject{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return StorageObject{}, err
|
||||||
|
}
|
||||||
|
return backend.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := boxObjectKey(box.ID, name)
|
||||||
|
return key, backend.Put(ctx, key, body, size, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) IsProtected(box Box) bool {
|
func (s *UploadService) IsProtected(box Box) bool {
|
||||||
return box.PasswordHash != "" && box.PasswordSalt != ""
|
return box.PasswordHash != "" && box.PasswordSalt != ""
|
||||||
}
|
}
|
||||||
@@ -445,11 +826,11 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
|
|||||||
defer archive.Close()
|
defer archive.Close()
|
||||||
|
|
||||||
for _, file := range box.Files {
|
for _, file := range box.Files {
|
||||||
path := s.FilePath(box, file)
|
object, err := s.OpenFileObject(context.Background(), box, file)
|
||||||
source, err := os.Open(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
source := object.Body
|
||||||
|
|
||||||
header := &zip.FileHeader{
|
header := &zip.FileHeader{
|
||||||
Name: file.Name,
|
Name: file.Name,
|
||||||
@@ -473,6 +854,9 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) SaveBox(box Box) error {
|
func (s *UploadService) SaveBox(box Box) error {
|
||||||
|
if box.StorageBackendID == "" {
|
||||||
|
box.StorageBackendID = StorageBackendLocal
|
||||||
|
}
|
||||||
data, err := json.Marshal(box)
|
data, err := json.Marshal(box)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -490,19 +874,28 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
|||||||
files := make([]ResultFile, 0, len(box.Files))
|
files := make([]ResultFile, 0, len(box.Files))
|
||||||
for _, file := range box.Files {
|
for _, file := range box.Files {
|
||||||
files = append(files, ResultFile{
|
files = append(files, ResultFile{
|
||||||
ID: file.ID,
|
ID: file.ID,
|
||||||
Name: file.Name,
|
Name: file.Name,
|
||||||
Size: helpers.FormatBytes(file.Size),
|
Size: helpers.FormatBytes(file.Size),
|
||||||
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
|
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{
|
result := UploadResult{
|
||||||
BoxID: box.ID,
|
BoxID: box.ID,
|
||||||
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
||||||
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
||||||
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
ThumbnailURL: thumbnailURL,
|
||||||
Files: files,
|
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
||||||
|
Files: files,
|
||||||
}
|
}
|
||||||
if deleteToken != "" {
|
if deleteToken != "" {
|
||||||
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
|
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
|
||||||
@@ -518,18 +911,44 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
|
|||||||
}
|
}
|
||||||
defer target.Close()
|
defer target.Close()
|
||||||
|
|
||||||
written, err := io.Copy(target, io.LimitReader(source, maxSize+1))
|
var written int64
|
||||||
|
if maxSize <= 0 {
|
||||||
|
written, err = io.Copy(target, source)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if written > maxSize {
|
if maxSize > 0 && written > maxSize {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
return fmt.Errorf("file exceeds max upload size")
|
return fmt.Errorf("file exceeds max upload size")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source multipart.File, size, maxSize int64, contentType string) error {
|
||||||
|
var reader io.Reader = source
|
||||||
|
if maxSize > 0 {
|
||||||
|
reader = io.LimitReader(source, maxSize+1)
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
written, err := io.Copy(&buffer, reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if written > maxSize {
|
||||||
|
return fmt.Errorf("file exceeds max upload size")
|
||||||
|
}
|
||||||
|
return backend.Put(ctx, key, bytes.NewReader(buffer.Bytes()), written, contentType)
|
||||||
|
}
|
||||||
|
return backend.Put(ctx, key, reader, size, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boxObjectKey(boxID, name string) string {
|
||||||
|
return filepath.ToSlash(filepath.Join(boxID, name))
|
||||||
|
}
|
||||||
|
|
||||||
func randomID(byteCount int) string {
|
func randomID(byteCount int) string {
|
||||||
data := make([]byte, byteCount)
|
data := make([]byte, byteCount)
|
||||||
if _, err := rand.Read(data); err != nil {
|
if _, err := rand.Read(data); err != nil {
|
||||||
@@ -567,10 +986,13 @@ func previewKind(contentType string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) writeBoxMetadata(box Box) error {
|
func (s *UploadService) writeBoxMetadata(box Box) error {
|
||||||
path := s.BoxMetadataPath(box)
|
|
||||||
data, err := json.MarshalIndent(box, "", " ")
|
data, err := json.MarshalIndent(box, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0o600)
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return backend.Put(context.Background(), boxObjectKey(box.ID, ".warpbox.box.json"), bytes.NewReader(data), int64(len(data)), "application/json")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeleteTokenVerification(t *testing.T) {
|
func TestDeleteTokenVerification(t *testing.T) {
|
||||||
@@ -59,6 +61,309 @@ func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
active, err := service.CreateBox(testFileHeaders(t, "file", "active.txt", "active"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBox active returned error: %v", err)
|
||||||
|
}
|
||||||
|
expired, err := service.CreateBox(testFileHeaders(t, "file", "expired.txt", "expired"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBox expired returned error: %v", err)
|
||||||
|
}
|
||||||
|
expiredBox, err := service.GetBox(expired.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
expiredBox.ExpiresAt = time.Now().UTC().Add(-time.Hour)
|
||||||
|
if err := service.SaveBox(expiredBox); err != nil {
|
||||||
|
t.Fatalf("SaveBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBox, err := service.GetBox(active.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox active returned error: %v", err)
|
||||||
|
}
|
||||||
|
want := activeBox.Files[0].Size
|
||||||
|
got, err := service.UserActiveStorageUsed("user-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserActiveStorageUsed returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("UserActiveStorageUsed = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalStorageBackendAndLegacyFallback(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
result := createTestBox(t, service, "file.txt", "hello")
|
||||||
|
box := getTestBox(t, service, result.BoxID)
|
||||||
|
if service.BoxStorageBackendID(box) != StorageBackendLocal {
|
||||||
|
t.Fatalf("BoxStorageBackendID = %q", service.BoxStorageBackendID(box))
|
||||||
|
}
|
||||||
|
if box.Files[0].ObjectKey == "" {
|
||||||
|
t.Fatalf("new file did not store object key")
|
||||||
|
}
|
||||||
|
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFileObject returned error: %v", err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(object.Body)
|
||||||
|
object.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "hello" {
|
||||||
|
t.Fatalf("object body = %q", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
box.StorageBackendID = ""
|
||||||
|
box.Files[0].ObjectKey = ""
|
||||||
|
object, err = service.OpenFileObject(testContext(), box, box.Files[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy OpenFileObject returned error: %v", err)
|
||||||
|
}
|
||||||
|
object.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContaboStorageConfigAllowsDisplayNamesWithSpaces(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
||||||
|
Provider: StorageProviderContabo,
|
||||||
|
Name: "Contabo main",
|
||||||
|
Endpoint: "https://eu2.contabostorage.com",
|
||||||
|
Region: "EU",
|
||||||
|
Bucket: "My Main Bucket",
|
||||||
|
AccessKey: "access",
|
||||||
|
SecretKey: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateS3Backend returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Provider != StorageProviderContabo || !cfg.UseSSL || !cfg.PathStyle {
|
||||||
|
t.Fatalf("contabo config was not normalized: %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.Bucket != "My Main Bucket" {
|
||||||
|
t.Fatalf("bucket = %q", cfg.Bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSFTPStorageConfigValidation(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
||||||
|
Provider: StorageProviderSFTP,
|
||||||
|
Name: "NAS storage",
|
||||||
|
Host: "files.example.test",
|
||||||
|
Username: "warpbox",
|
||||||
|
Password: "secret",
|
||||||
|
RemotePath: "/srv/warpbox//",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateS3Backend returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Type != StorageBackendSFTP || cfg.Provider != StorageProviderSFTP {
|
||||||
|
t.Fatalf("sftp config type/provider = %+v", cfg)
|
||||||
|
}
|
||||||
|
if cfg.Port != 22 {
|
||||||
|
t.Fatalf("port = %d, want 22", cfg.Port)
|
||||||
|
}
|
||||||
|
if cfg.RemotePath != "/srv/warpbox" {
|
||||||
|
t.Fatalf("remote path = %q", cfg.RemotePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
Provider: StorageProviderSMB,
|
||||||
|
Name: "Office NAS",
|
||||||
|
Host: "nas.example.test",
|
||||||
|
Username: "warpbox",
|
||||||
|
Password: "secret",
|
||||||
|
Share: "uploads",
|
||||||
|
RemotePath: "/warpbox//",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateS3Backend smb returned error: %v", err)
|
||||||
|
}
|
||||||
|
if smb.Type != StorageBackendSMB || smb.Provider != StorageProviderSMB || smb.Port != 445 {
|
||||||
|
t.Fatalf("smb config was not normalized: %+v", smb)
|
||||||
|
}
|
||||||
|
if smb.RemotePath != "/warpbox" {
|
||||||
|
t.Fatalf("smb remote path = %q", smb.RemotePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
webdav, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
||||||
|
Provider: StorageProviderWebDAV,
|
||||||
|
Name: "Nextcloud",
|
||||||
|
Endpoint: "https://files.example.test/webdav",
|
||||||
|
Username: "warpbox",
|
||||||
|
Password: "secret",
|
||||||
|
RemotePath: "/warpbox",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateS3Backend webdav returned error: %v", err)
|
||||||
|
}
|
||||||
|
if webdav.Type != StorageBackendWebDAV || webdav.Provider != StorageProviderWebDAV {
|
||||||
|
t.Fatalf("webdav config was not normalized: %+v", webdav)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testContext() context.Context {
|
||||||
|
return context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
func newTestUploadService(t *testing.T) *UploadService {
|
func newTestUploadService(t *testing.T) *UploadService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|||||||
@@ -8,22 +8,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
appName string
|
appName string
|
||||||
baseURL string
|
appVersion string
|
||||||
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
AppName string
|
AppName string
|
||||||
|
AppVersion string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
|
CurrentUser any
|
||||||
|
CSRFToken string
|
||||||
Data any
|
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"))
|
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -54,14 +58,16 @@ func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &Renderer{
|
return &Renderer{
|
||||||
templates: templates,
|
templates: templates,
|
||||||
appName: appName,
|
appName: appName,
|
||||||
baseURL: baseURL,
|
appVersion: appVersion,
|
||||||
|
baseURL: baseURL,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
|
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
|
||||||
data.AppName = r.appName
|
data.AppName = r.appName
|
||||||
|
data.AppVersion = r.appVersion
|
||||||
data.BaseURL = r.baseURL
|
data.BaseURL = r.baseURL
|
||||||
data.CurrentYear = time.Now().Year()
|
data.CurrentYear = time.Now().Year()
|
||||||
|
|
||||||
|
|||||||
BIN
backend/static/WarpBoxLogo.png
Normal file
|
After Width: | Height: | Size: 423 B |
BIN
backend/static/backgrounds/stars1.gif
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
444
backend/static/css/50-admin.css
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
.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: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
height: 180px;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-col {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 2.2rem;
|
||||||
|
min-height: 2px;
|
||||||
|
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
@@ -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
@@ -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
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf
Normal file
BIN
backend/static/fonts/pixel_operator/PixelOperatorMono.ttf
Normal file
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf
Normal file
BIN
backend/static/fonts/pixeloid_sans/PixeloidSans.ttf
Normal file
6
backend/static/icons/regular/accessibility-sign.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.5 12.5L18.5 12L17 18.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.5 12.5L16 7.5L12.5 5L10 7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18.5 6.5C17.3954 6.5 16.5 5.60457 16.5 4.5C16.5 3.39543 17.3954 2.5 18.5 2.5C19.6046 2.5 20.5 3.39543 20.5 4.5C20.5 5.60457 19.6046 6.5 18.5 6.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M5.49951 12.5C6.33526 11.8721 7.37418 11.5 8.5 11.5C11.2614 11.5 13.5 13.7386 13.5 16.5C13.5 17.2111 13.3516 17.8875 13.084 18.5M3.7289 15C3.58018 15.4735 3.5 15.9774 3.5 16.5C3.5 19.2614 5.73858 21.5 8.5 21.5C9.41072 21.5 10.2646 21.2565 11 20.8311" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 902 B |
7
backend/static/icons/regular/accessibility-tech.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19Z" stroke="currentColor"/>
|
||||||
|
<path d="M12.5 12.1605L16.5 12L16 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11.8333 12L13.5 9.53846L10.8333 8L9.5 9.84615" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15.5 7.5C15.2239 7.5 15 7.27614 15 7C15 6.72386 15.2239 6.5 15.5 6.5C15.7761 6.5 16 6.72386 16 7C16 7.27614 15.7761 7.5 15.5 7.5Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.5 18C8.84315 18 7.5 16.6569 7.5 15C7.5 13.3431 8.84315 12 10.5 12C12.1569 12 13.5 13.3431 13.5 15C13.5 16.6569 12.1569 18 10.5 18Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 964 B |
5
backend/static/icons/regular/accessibility.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 9L12 10M17 9L12 10M12 10V13M12 13L10 18M12 13L14 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 7C11.7239 7 11.5 6.77614 11.5 6.5C11.5 6.22386 11.7239 6 12 6C12.2761 6 12.5 6.22386 12.5 6.5C12.5 6.77614 12.2761 7 12 7Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 681 B |
3
backend/static/icons/regular/activity.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 12H6L9 3L15 21L18 12H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 230 B |
5
backend/static/icons/regular/adobe-after-effects.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 13V12C14 10.8954 14.8954 10 16 10V10C17.1046 10 18 10.8954 18 12V13H14ZM14 13V14C14 15.1046 14.8954 16 16 16H17.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 16L7.125 13M12 16L10.875 13M7.125 13L9 8L10.875 13M7.125 13L10.875 13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 677 B |
6
backend/static/icons/regular/adobe-illustrator.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 12L16 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 8.99977L16 9.00977" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 16L8.125 13M13 16L11.875 13M8.125 13L10 8L11.875 13M8.125 13L11.875 13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 677 B |
5
backend/static/icons/regular/adobe-indesign.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.5 8L8.5 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15.5 12V15.4C15.5 15.7314 15.2314 16 14.9 16H13.5C12.3954 16 11.5 15.1046 11.5 14V14C11.5 12.8954 12.3954 12 13.5 12H15.5ZM15.5 12V9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 634 B |
5
backend/static/icons/regular/adobe-lightroom.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 8L7 16L11 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14 10.5L14 13M14 16L14 13M14 13C14 13 14 10.5 17 10.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 557 B |
5
backend/static/icons/regular/adobe-photoshop.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 16L7 12M7 12L7 8L9 8C10.1046 8 11 8.89543 11 10V10C11 11.1046 10.1046 12 9 12L7 12Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17 11V11C16.6936 10.3871 16.0672 10 15.382 10H15C14.1716 10 13.5 10.6716 13.5 11.5V11.5C13.5 12.3284 14.1716 13 15 13H15.5C16.3284 13 17 13.6716 17 14.5V14.5C17 15.3284 16.3284 16 15.5 16H15.118C14.4328 16 13.8064 15.6129 13.5 15V15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 807 B |
5
backend/static/icons/regular/adobe-xd.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 8L11 16M7 16L11 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17 12V15.4C17 15.7314 16.7314 16 16.4 16H15C13.8954 16 13 15.1046 13 14V14C13 12.8954 13.8954 12 15 12H17ZM17 12V9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 623 B |
4
backend/static/icons/regular/african-tree.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22L12 12M12 8L12 12M12 12L15 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.4243 18.5757L18.593 12.4071C20.9331 10.0669 20.6927 6.2053 18.0804 4.17349C14.5041 1.39191 9.49616 1.39192 5.91984 4.1735C3.3075 6.20532 3.06707 10.067 5.40723 12.4071L11.5758 18.5757C11.8101 18.81 12.19 18.81 12.4243 18.5757Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 550 B |
6
backend/static/icons/regular/agile.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.5 19H22M22 19L19.5 16.5M22 19L19.5 21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 2L9.5 4.5L12 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.5 4.5C14.6421 4.5 18 7.85786 18 12C18 16.1421 14.6421 19.5 10.5 19.5H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6.75583 5.5C4.51086 6.79595 3 9.22154 3 12C3 13.6884 3.55792 15.2465 4.49945 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 667 B |
9
backend/static/icons/regular/air-conditioner.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 3.6V11H2V3.6C2 3.26863 2.26863 3 2.6 3H21.4C21.7314 3 22 3.26863 22 3.6Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M18 7H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 11L2.78969 13.5844C3.04668 14.4255 3.82294 15 4.70239 15H6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 11L21.2103 13.5844C20.9533 14.4255 20.1771 15 19.2976 15H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.5 14.5C9.5 14.5 9.5 21.5 6 21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M14.5 14.5C14.5 14.5 14.5 21.5 18 21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 14.5V21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 996 B |
7
backend/static/icons/regular/airplane-helix-45deg.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.1207 14.1213C15.2922 12.9497 15.2922 11.0503 14.1207 9.87868C12.9491 8.70711 11.0496 8.70711 9.87803 9.87868C8.70646 11.0503 8.70646 12.9497 9.87803 14.1213C11.0496 15.2929 12.9491 15.2929 14.1207 14.1213Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.87868 9.87863C9.87868 9.87863 7.07642 9.88782 5.63604 8.46441C4.22749 7.05444 2.77156 5.67063 4.22183 4.22177C5.59998 2.84504 7.03117 4.20692 8.46447 5.63599C9.8702 7.03747 9.87868 9.87863 9.87868 9.87863Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1214 9.87868C14.1214 9.87868 14.1122 7.07642 15.5356 5.63604C16.9456 4.22749 18.3294 2.77156 19.7782 4.22183C21.155 5.59998 19.7931 7.03117 18.364 8.46447C16.9625 9.8702 14.1214 9.87868 14.1214 9.87868Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.87863 14.1213C9.87863 14.1213 9.88782 16.9236 8.46441 18.364C7.05444 19.7725 5.67063 21.2284 4.22177 19.7782C2.84504 18.4 4.20692 16.9688 5.63599 15.5355C7.03747 14.1298 9.87863 14.1213 9.87863 14.1213Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1213 14.1214C14.1213 14.1214 16.9236 14.1122 18.364 15.5356C19.7725 16.9456 21.2284 18.3294 19.7782 19.7782C18.4 21.155 16.9688 19.7931 15.5355 18.364C14.1298 16.9625 14.1213 14.1214 14.1213 14.1214Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
7
backend/static/icons/regular/airplane-helix.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.9996 14.9995C13.6565 14.9995 14.9996 13.6564 14.9996 11.9995C14.9996 10.3427 13.6565 8.99951 11.9996 8.99951C10.3428 8.99951 8.99963 10.3427 8.99963 11.9995C8.99963 13.6564 10.3428 14.9995 11.9996 14.9995Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 9C12 9 10.012 7.025 10 5C10.001 3.007 9.95 0.999 12 1C13.948 1.001 13.997 2.976 14 5C14.003 6.985 12 9 12 9Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 12C15 12 16.975 10.012 19 10C20.993 10.001 23.001 9.95 23 12C22.999 13.948 21.024 13.997 19 14C17.015 14.003 15 12 15 12Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 12 7.025 13.988 5 14C3.007 13.999 0.999 14.05 1 12C1.001 10.052 2.976 10.003 5 10C6.985 9.997 9 12 9 12Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 15C12 15 13.988 16.975 14 19C13.999 20.993 14.05 23.001 12 23C10.052 22.999 10.003 21.024 10 19C9.997 17.015 12 15 12 15Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
4
backend/static/icons/regular/airplane-off.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.88099 9.88688L2.782 14.3237C2.60657 14.4334 2.5 14.6257 2.5 14.8325V15.7315C2.5 16.1219 2.86683 16.4083 3.24552 16.3136L9.75448 14.6864C10.1332 14.5917 10.5 14.8781 10.5 15.2685V18.2277C10.5 18.4008 10.4253 18.5654 10.2951 18.6793L8.13481 20.5695C7.6765 20.9706 8.03808 21.7203 8.63724 21.6114L11.8927 21.0195C11.9636 21.0066 12.0364 21.0066 12.1073 21.0195L15.3628 21.6114C15.9619 21.7203 16.3235 20.9706 15.8652 20.5695L13.7049 18.6793C13.5747 18.5654 13.5 18.4008 13.5 18.2277V15.2685C13.5 14.8781 13.8668 14.5917 14.2455 14.6864L14.7029 14.8007M10.5 7.5V4.5C10.5 3.67157 11.1716 3 12 3V3C12.8284 3 13.5 3.67157 13.5 4.5V9.16745C13.5 9.37433 13.6066 9.56661 13.782 9.67625L21.218 14.3237C21.3934 14.4334 21.5 14.6257 21.5 14.8325V15.7315C21.5 16.1219 21.1332 16.4083 20.7545 16.3136L18.7493 15.8123" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 3L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
7
backend/static/icons/regular/airplane-rotation.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.87868 14.1218C11.0503 15.2934 12.9497 15.2934 14.1213 14.1218C15.2929 12.9502 15.2929 11.0507 14.1213 9.87913C12.9497 8.70756 11.0503 8.70756 9.87868 9.87913C8.70711 11.0507 8.7071 12.9502 9.87868 14.1218Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.37076 16.7726C4.09132 16.3274 3.84879 15.8547 3.64986 15.3612C3.23116 14.323 3.00098 13.1891 3.00098 12.0012C3.00098 7.7649 5.93471 4.20879 9.8792 3.25392C10.5594 3.0891 11.2698 3.00195 12.0002 3.00195" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19.7148 7.3667C20.5304 8.72132 20.9993 10.3061 20.9993 12.0008C20.9993 15.807 18.6311 19.0638 15.29 20.3786C14.2708 20.7793 13.1605 21 12.0001 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1213 9.87918C14.1213 9.87918 14.1121 7.07691 15.5355 5.63653C16.9455 4.22798 18.3293 2.77204 19.7782 4.22232C21.1549 5.60047 19.793 7.03166 18.364 8.46496C16.9625 9.87069 14.1213 9.87918 14.1213 9.87918Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.87869 14.1208C9.87869 14.1208 9.88788 16.9231 8.46448 18.3635C7.0545 19.772 5.6707 21.228 4.22183 19.7777C2.8451 18.3995 4.20698 16.9683 5.63605 15.535C7.03753 14.1293 9.87869 14.1208 9.87869 14.1208Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
3
backend/static/icons/regular/airplane.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10.5 4.5V9.16745C10.5 9.37433 10.3934 9.56661 10.218 9.67625L2.782 14.3237C2.60657 14.4334 2.5 14.6257 2.5 14.8325V15.7315C2.5 16.1219 2.86683 16.4083 3.24552 16.3136L9.75448 14.6864C10.1332 14.5917 10.5 14.8781 10.5 15.2685V18.2277C10.5 18.4008 10.4253 18.5654 10.2951 18.6793L8.13481 20.5695C7.6765 20.9706 8.03808 21.7204 8.63724 21.6114L11.8927 21.0195C11.9636 21.0066 12.0364 21.0066 12.1073 21.0195L15.3628 21.6114C15.9619 21.7204 16.3235 20.9706 15.8652 20.5695L13.7049 18.6793C13.5747 18.5654 13.5 18.4008 13.5 18.2277V15.2685C13.5 14.8781 13.8668 14.5917 14.2455 14.6864L20.7545 16.3136C21.1332 16.4083 21.5 16.1219 21.5 15.7315V14.8325C21.5 14.6257 21.3934 14.4334 21.218 14.3237L13.782 9.67625C13.6066 9.56661 13.5 9.37433 13.5 9.16745V4.5C13.5 3.67157 12.8284 3 12 3C11.1716 3 10.5 3.67157 10.5 4.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1016 B |
4
backend/static/icons/regular/airplay.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 17L3 17L3 4L21 4L21 17L18 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.62188 19.0672L11.5008 14.7488C11.7383 14.3926 12.2617 14.3926 12.4992 14.7488L15.3781 19.0672C15.6439 19.4659 15.3581 20 14.8789 20H9.12111C8.64189 20 8.35606 19.4659 8.62188 19.0672Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 475 B |
6
backend/static/icons/regular/alarm.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17 13H12V8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M5 3.5L7 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19 3.5L17 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 22C16.9706 22 21 17.9706 21 13C21 8.02944 16.9706 4 12 4C7.02944 4 3 8.02944 3 13C3 17.9706 7.02944 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 596 B |
5
backend/static/icons/regular/album-carousel.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 19.4V4.6C2 4.26863 2.26863 4 2.6 4H17.4C17.7314 4 18 4.26863 18 4.6V19.4C18 19.7314 17.7314 20 17.4 20H2.6C2.26863 20 2 19.7314 2 19.4Z" stroke="currentColor"/>
|
||||||
|
<path d="M22 6V18" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M11 14.5C11 15.3284 10.3284 16 9.5 16C8.67157 16 8 15.3284 8 14.5C8 13.6716 8.67157 13 9.5 13C10.3284 13 11 13.6716 11 14.5ZM11 14.5V8.6C11 8.26863 11.2686 8 11.6 8H13" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 586 B |
5
backend/static/icons/regular/album-list.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 17.4V2.6C2 2.26863 2.26863 2 2.6 2H17.4C17.7314 2 18 2.26863 18 2.6V17.4C18 17.7314 17.7314 18 17.4 18H2.6C2.26863 18 2 17.7314 2 17.4Z" stroke="currentColor"/>
|
||||||
|
<path d="M8 22H21.4C21.7314 22 22 21.7314 22 21.4V8" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M11 12.5C11 13.3284 10.3284 14 9.5 14C8.67157 14 8 13.3284 8 12.5C8 11.6716 8.67157 11 9.5 11C10.3284 11 11 11.6716 11 12.5ZM11 12.5V6.6C11 6.26863 11.2686 6 11.6 6H13" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 620 B |
6
backend/static/icons/regular/album-open.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 2.20001C19.5645 3.12655 23 7.16206 23 12C23 16.8379 19.5645 20.8734 15 21.7999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 9C16.1411 9.28364 17 10.519 17 12C17 13.481 16.1411 14.7164 15 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1 2L11 2L11 22L1 22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 15.5C4 16.3284 3.32843 17 2.5 17C1.67157 17 1 16.3284 1 15.5C1 14.6716 1.67157 14 2.5 14C3.32843 14 4 14.6716 4 15.5ZM4 15.5V7.6C4 7.26863 4.26863 7 4.6 7H7" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 756 B |
4
backend/static/icons/regular/album.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z" stroke="currentColor"/>
|
||||||
|
<path d="M12 15.5C12 16.3284 11.3284 17 10.5 17C9.67157 17 9 16.3284 9 15.5C9 14.6716 9.67157 14 10.5 14C11.3284 14 12 14.6716 12 15.5ZM12 15.5V7.6C12 7.26863 12.2686 7 12.6 7H15" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
10
backend/static/icons/regular/align-bottom-box.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 8.00001L4.01 8.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 4.00001L4.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 4.00001L8.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 4.00001L12.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 4.00001L16.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20 4.00001L20.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20 8.00001L20.01 8.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 12V20H20V12H4Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 964 B |
6
backend/static/icons/regular/align-center.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 10L18 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 18L18 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22L12 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M19 16H5C3.89543 16 3 15.1046 3 14L3 10C3 8.89543 3.89543 8 5 8H19C20.1046 8 21 8.89543 21 10V14C21 15.1046 20.1046 16 19 16Z" stroke="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 375 B |