Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42449b3322 | |||
| 1513030c2a | |||
| ac9b8232f3 | |||
| 704efb019c | |||
| 48d3c0475f | |||
| ffe4201f05 | |||
| df91fe9d3d | |||
| f1c67c455b | |||
| 61b7c283a4 | |||
| d99f8ee82a | |||
| 0503fad9af | |||
| 3423c141be | |||
| c3558fd353 | |||
| 830d2a885c |
11
.env.example
@@ -16,6 +16,17 @@ WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
|
|||||||
WARPBOX_USER_DAILY_UPLOAD_MB=8192
|
WARPBOX_USER_DAILY_UPLOAD_MB=8192
|
||||||
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
|
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
|
||||||
WARPBOX_USAGE_RETENTION_DAYS=30
|
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
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
20
README.md
@@ -22,6 +22,17 @@ Upload policy defaults are also configured in megabytes and can later be changed
|
|||||||
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
|
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
|
||||||
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
|
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
|
||||||
- `WARPBOX_USAGE_RETENTION_DAYS=30`
|
- `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`
|
||||||
|
|
||||||
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.
|
||||||
@@ -126,6 +137,11 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your
|
|||||||
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
||||||
user storage quota, and usage retention.
|
user storage quota, and usage retention.
|
||||||
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
- `/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.
|
||||||
|
- 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
|
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
|
||||||
tokens, thumbnails, and cleanup continue to work as before.
|
tokens, thumbnails, and cleanup continue to work as before.
|
||||||
|
|
||||||
@@ -136,8 +152,8 @@ support will power public forgot-password and optional email delivery.
|
|||||||
|
|
||||||
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` also stores users, sessions, invites, and collections.
|
||||||
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
|
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
|
||||||
|
|||||||
@@ -3,9 +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.33.0
|
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.30.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,18 +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=
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sys v0.30.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
|
||||||
@@ -38,11 +39,23 @@ type SettingsDefaults struct {
|
|||||||
UserDailyUploadMB float64
|
UserDailyUploadMB float64
|
||||||
DefaultUserStorageMB float64
|
DefaultUserStorageMB float64
|
||||||
UsageRetentionDays int
|
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"), "/"),
|
||||||
@@ -61,11 +74,22 @@ func Load() (Config, error) {
|
|||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||||
DefaultSettings: SettingsDefaults{
|
DefaultSettings: SettingsDefaults{
|
||||||
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||||
AnonymousMaxUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
||||||
AnonymousDailyUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048),
|
AnonymousDailyUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048),
|
||||||
UserDailyUploadMB: envMegabytesFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
|
UserDailyUploadMB: envMegabytesLimitFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
|
||||||
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
|
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
|
||||||
UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30),
|
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"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +99,21 @@ 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 cfg.DefaultSettings.AnonymousMaxUploadMB <= 0 ||
|
if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) ||
|
||||||
cfg.DefaultSettings.AnonymousDailyUploadMB <= 0 ||
|
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) ||
|
||||||
cfg.DefaultSettings.UserDailyUploadMB <= 0 ||
|
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) ||
|
||||||
cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
|
cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
|
||||||
cfg.DefaultSettings.UsageRetentionDays <= 0 {
|
cfg.DefaultSettings.UsageRetentionDays <= 0 ||
|
||||||
return Config{}, fmt.Errorf("upload policy settings must be positive")
|
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
|
||||||
@@ -172,6 +205,35 @@ func envMegabytesFloat(key string, fallback float64) float64 {
|
|||||||
return parsed
|
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)
|
sizeMB, err := parseMegabytesFloat(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -198,6 +260,35 @@ func parseMegabytesFloat(value string) (float64, error) {
|
|||||||
return 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 {
|
||||||
return int64(math.Round(sizeMB * 1024 * 1024))
|
return int64(math.Round(sizeMB * 1024 * 1024))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -67,6 +68,87 @@ func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBearerTokenUploadActsAsUser(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("token upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if box.OwnerID != user.ID {
|
||||||
|
t.Fatalf("OwnerID = %q, want %q", box.OwnerID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invalid bearer token is an authentication failure, not an anonymous upload.
|
||||||
|
badRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "x.txt", "x")
|
||||||
|
badRequest.Header.Set("Accept", "application/json")
|
||||||
|
badRequest.Header.Set("Authorization", "Bearer wbx_bogus.secret")
|
||||||
|
badResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(badResponse, badRequest)
|
||||||
|
if badResponse.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("invalid token upload status = %d, body = %s", badResponse.Code, badResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnonymousUploadWithoutBearerStillWorks(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, multipartUploadRequest(t, "/api/v1/upload", "file", "anonymous.txt", "anonymous"))
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("anonymous upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisabledUserBearerTokenCannotUpload(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := app.authService.DisableUser(user.ID, true); err != nil {
|
||||||
|
t.Fatalf("DisableUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "blocked.txt", "blocked")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("disabled bearer upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -139,6 +221,29 @@ func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUnlimitedAnonymousUploadPolicyUsesNegativeOne(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
policy, err := app.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy.AnonymousMaxUploadMB = -1
|
||||||
|
policy.AnonymousDailyUploadMB = -1
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("unlimited anonymous upload status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnonymousUploadDisabled(t *testing.T) {
|
func TestAnonymousUploadDisabled(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -269,6 +374,104 @@ func TestSignedInDailyCap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLayeredUploadLimits(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.AnonymousDailyBoxes = 1
|
||||||
|
policy.AnonymousActiveBoxes = 10
|
||||||
|
policy.AnonymousMaxDays = 3
|
||||||
|
policy.LocalStorageMaxGB = 0.001
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := uploadThroughApp(t, app)
|
||||||
|
if first.BoxID == "" {
|
||||||
|
t.Fatalf("first upload did not return a box id")
|
||||||
|
}
|
||||||
|
secondRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
|
||||||
|
secondRequest.Header.Set("Accept", "application/json")
|
||||||
|
secondResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(secondResponse, secondRequest)
|
||||||
|
if secondResponse.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("daily box status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.AnonymousDailyBoxes = 10
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
expiryRequest := multipartUploadRequestWithField(t, "/api/v1/upload", "file", "expiry.txt", "hello", "max_days", "30")
|
||||||
|
expiryRequest.Header.Set("Accept", "application/json")
|
||||||
|
expiryResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(expiryResponse, expiryRequest)
|
||||||
|
if expiryResponse.Code != http.StatusRequestEntityTooLarge && expiryResponse.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("expiry/box status = %d, body = %s", expiryResponse.Code, expiryResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
dailyBoxes := 1
|
||||||
|
maxDays := 1
|
||||||
|
if err := app.authService.SetUserPolicy(user.ID, services.UserPolicy{DailyBoxes: &dailyBoxes, MaxDays: &maxDays}); err != nil {
|
||||||
|
t.Fatalf("SetUserPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login(user.Email, "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
first := multipartUploadRequest(t, "/api/v1/upload", "file", "one.txt", "hello")
|
||||||
|
first.Header.Set("Accept", "application/json")
|
||||||
|
first.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
firstResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(firstResponse, first)
|
||||||
|
if firstResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("first status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
|
||||||
|
}
|
||||||
|
second := multipartUploadRequest(t, "/api/v1/upload", "file", "two.txt", "hello")
|
||||||
|
second.Header.Set("Accept", "application/json")
|
||||||
|
second.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
secondResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(secondResponse, second)
|
||||||
|
if secondResponse.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("second status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalStorageCapRejectsUpload(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
policy := testPolicy(t, app)
|
||||||
|
policy.AnonymousMaxUploadMB = 4
|
||||||
|
policy.AnonymousDailyUploadMB = 8
|
||||||
|
policy.LocalStorageMaxGB = 0.001
|
||||||
|
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.Upload(response, request)
|
||||||
|
if response.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("storage cap status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
|
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -281,10 +484,11 @@ func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
|
|||||||
t.Fatalf("Login returned error: %v", err)
|
t.Fatalf("Login returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30")
|
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30&csrf_token=test-csrf")
|
||||||
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
|
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
|
||||||
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
|
||||||
|
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||||
settingsResponse := httptest.NewRecorder()
|
settingsResponse := httptest.NewRecorder()
|
||||||
app.AdminSettingsPost(settingsResponse, settingsRequest)
|
app.AdminSettingsPost(settingsResponse, settingsRequest)
|
||||||
if settingsResponse.Code != http.StatusSeeOther {
|
if settingsResponse.Code != http.StatusSeeOther {
|
||||||
@@ -320,9 +524,10 @@ func TestAdminUserQuotaPostChangesEnforcement(t *testing.T) {
|
|||||||
t.Fatalf("admin Login returned error: %v", err)
|
t.Fatalf("admin Login returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001"))
|
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001&csrf_token=test-csrf"))
|
||||||
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
quotaRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||||
quotaRequest.SetPathValue("userID", user.ID)
|
quotaRequest.SetPathValue("userID", user.ID)
|
||||||
quotaResponse := httptest.NewRecorder()
|
quotaResponse := httptest.NewRecorder()
|
||||||
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)
|
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)
|
||||||
@@ -364,6 +569,9 @@ func TestHomeReflectsUploadPolicySettings(t *testing.T) {
|
|||||||
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
|
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
|
||||||
t.Fatalf("home did not reflect policy settings: %s", body)
|
t.Fatalf("home did not reflect policy settings: %s", body)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(body, "warpbox.dev · test ·") {
|
||||||
|
t.Fatalf("home footer did not include app version: %s", body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
|
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
|
||||||
@@ -387,7 +595,7 @@ func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body := response.Body.String()
|
body := response.Body.String()
|
||||||
header := body[:strings.Index(body, "<main")]
|
header := body[:strings.Index(body, "<main")]
|
||||||
if !strings.Contains(header, "My Account") || strings.Contains(header, ">Login<") || strings.Contains(header, "Health") {
|
if !strings.Contains(header, "Dashboard") || strings.Contains(header, "Sign in") || strings.Contains(header, "Health") {
|
||||||
t.Fatalf("api header did not reflect logged-in state: %s", body)
|
t.Fatalf("api header did not reflect logged-in state: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,11 +612,195 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body := response.Body.String()
|
body := response.Body.String()
|
||||||
header := body[:strings.Index(body, "<main")]
|
header := body[:strings.Index(body, "<main")]
|
||||||
if !strings.Contains(header, ">Login<") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "My Account") {
|
if !strings.Contains(header, "Sign in") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "Dashboard") {
|
||||||
t.Fatalf("api header did not reflect logged-out state: %s", body)
|
t.Fatalf("api header did not reflect logged-out state: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
adminToken := createAdminSession(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/storage/new/sftp", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.AdminNewStorageProvider(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AdminNewStorageProvider status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "Private key") || !strings.Contains(body, "SSH host key") {
|
||||||
|
t.Fatalf("sftp page did not render sftp fields: %s", body)
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"Bucket display name", "WebDAV URL", "Share</span>", "Access key"} {
|
||||||
|
if strings.Contains(body, unwanted) {
|
||||||
|
t.Fatalf("sftp page rendered irrelevant field %q: %s", unwanted, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||||
|
Provider: services.StorageProviderSFTP,
|
||||||
|
Name: "NAS",
|
||||||
|
Host: "files.example.test",
|
||||||
|
Username: "warpbox",
|
||||||
|
Password: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBackend returned error: %v", err)
|
||||||
|
}
|
||||||
|
editRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/"+cfg.ID+"/edit", nil)
|
||||||
|
editRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
editRequest.SetPathValue("backendID", cfg.ID)
|
||||||
|
editResponse := httptest.NewRecorder()
|
||||||
|
app.AdminEditStorageForm(editResponse, editRequest)
|
||||||
|
if editResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AdminEditStorageForm status = %d, body = %s", editResponse.Code, editResponse.Body.String())
|
||||||
|
}
|
||||||
|
editBody := editResponse.Body.String()
|
||||||
|
if !strings.Contains(editBody, "Immutable provider") || strings.Contains(editBody, "Bucket display name") || strings.Contains(editBody, "WebDAV URL") {
|
||||||
|
t.Fatalf("edit page did not stay provider-specific: %s", editBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStorageEditRejectsProviderMutation(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
adminToken := createAdminSession(t, app)
|
||||||
|
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||||
|
Provider: services.StorageProviderSFTP,
|
||||||
|
Name: "NAS",
|
||||||
|
Host: "files.example.test",
|
||||||
|
Username: "warpbox",
|
||||||
|
Password: "secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBackend returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := strings.NewReader("provider=s3&name=Changed&endpoint=https://s3.example.test&bucket=bucket&access_key=access&secret_key=secret&use_ssl=on&csrf_token=test-csrf")
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/edit", form)
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||||
|
request.SetPathValue("backendID", cfg.ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.AdminEditStorage(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("AdminEditStorage status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
stored, err := app.uploadService.Storage().BackendConfig(cfg.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BackendConfig returned error: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Provider != services.StorageProviderSFTP || stored.Type != services.StorageBackendSFTP || stored.Name != "NAS" {
|
||||||
|
t.Fatalf("storage backend mutated despite rejected provider change: %+v", stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
unauthorized := httptest.NewRecorder()
|
||||||
|
app.AdminRunStorageCleanup(unauthorized, httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil))
|
||||||
|
if unauthorized.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("unauthorized cleanup status = %d", unauthorized.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken := createAdminSession(t, app)
|
||||||
|
missingCSRFRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil)
|
||||||
|
missingCSRFRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
missingCSRFResponse := httptest.NewRecorder()
|
||||||
|
app.AdminRunStorageCleanup(missingCSRFResponse, missingCSRFRequest)
|
||||||
|
if missingCSRFResponse.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("missing csrf cleanup status = %d", missingCSRFResponse.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", strings.NewReader("csrf_token=test-csrf"))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.AdminRunStorageCleanup(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("authorized cleanup status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
adminToken := createAdminSession(t, app)
|
||||||
|
if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil {
|
||||||
|
t.Fatalf("TestBackend returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/admin/storage/local/speed-test", strings.NewReader("mode=custom&custom_file_count=2&custom_file_size_mb=0.001&csrf_token=test-csrf"))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||||
|
request.SetPathValue("backendID", services.StorageBackendLocal)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.AdminStartStorageSpeedTest(response, request)
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("AdminStartStorageSpeedTest status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
tests, err := app.uploadService.Storage().ListSpeedTests(services.StorageBackendLocal, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSpeedTests returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tests) != 1 {
|
||||||
|
t.Fatalf("speed tests len = %d, want 1", len(tests))
|
||||||
|
}
|
||||||
|
if tests[0].Mode != services.StorageSpeedModeCustom || tests[0].CustomFileCount != 2 || tests[0].CustomFileSizeMB != 0.001 {
|
||||||
|
t.Fatalf("custom speed test options were not stored: %+v", tests[0])
|
||||||
|
}
|
||||||
|
location := response.Header().Get("Location")
|
||||||
|
if !strings.Contains(location, "/admin/storage/local/tests") {
|
||||||
|
t.Fatalf("speed test redirect location = %q", location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminStorageTestingPageRendersHistory(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
adminToken := createAdminSession(t, app)
|
||||||
|
if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil {
|
||||||
|
t.Fatalf("TestBackend returned error: %v", err)
|
||||||
|
}
|
||||||
|
test, err := app.uploadService.Storage().StartSpeedTest(services.StorageBackendLocal, services.StorageSpeedModeSmall)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartSpeedTest returned error: %v", err)
|
||||||
|
}
|
||||||
|
app.uploadService.Storage().RunSpeedTest(context.Background(), test.ID)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests", nil)
|
||||||
|
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
request.SetPathValue("backendID", services.StorageBackendLocal)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.AdminStorageTests(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AdminStorageTests status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, "New Test") || !strings.Contains(body, "Many small files") || strings.Contains(body, "storage-test-menu") {
|
||||||
|
t.Fatalf("testing page missing expected page-based controls: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests.json", nil)
|
||||||
|
jsonRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||||
|
jsonRequest.SetPathValue("backendID", services.StorageBackendLocal)
|
||||||
|
jsonResponse := httptest.NewRecorder()
|
||||||
|
app.AdminStorageTestsJSON(jsonResponse, jsonRequest)
|
||||||
|
if jsonResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("AdminStorageTestsJSON status = %d, body = %s", jsonResponse.Code, jsonResponse.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonResponse.Body.String(), `"progress":100`) || !strings.Contains(jsonResponse.Body.String(), `"stage":"complete"`) {
|
||||||
|
t.Fatalf("tests json missing progress fields: %s", jsonResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
user, err := app.authService.UserByID(userID)
|
user, err := app.authService.UserByID(userID)
|
||||||
@@ -434,6 +826,19 @@ func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.Up
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createAdminSession(t *testing.T, app *App) string {
|
||||||
|
t.Helper()
|
||||||
|
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "registration is closed") {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
_, token, err := app.authService.Login("admin@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login returned error: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
|
func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
policy, err := app.settingsService.UploadPolicy()
|
policy, err := app.settingsService.UploadPolicy()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type App struct {
|
|||||||
uploadService *services.UploadService
|
uploadService *services.UploadService
|
||||||
authService *services.AuthService
|
authService *services.AuthService
|
||||||
settingsService *services.SettingsService
|
settingsService *services.SettingsService
|
||||||
|
rateLimiter *rateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
|
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
|
||||||
@@ -26,6 +27,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
|||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
settingsService: settingsService,
|
settingsService: settingsService,
|
||||||
|
rateLimiter: newRateLimiter(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
|
|||||||
if data.CurrentUser == nil {
|
if data.CurrentUser == nil {
|
||||||
data.CurrentUser = a.currentPublicUser(r)
|
data.CurrentUser = a.currentPublicUser(r)
|
||||||
}
|
}
|
||||||
|
data.CSRFToken = a.csrfToken(w, r)
|
||||||
a.renderer.Render(w, status, page, data)
|
a.renderer.Render(w, status, page, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,18 +56,47 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
|
||||||
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
mux.HandleFunc("GET /account/settings", a.AccountSettings)
|
||||||
mux.HandleFunc("POST /account/password", a.ChangePassword)
|
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", a.AdminUsers)
|
||||||
|
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
|
||||||
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
|
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
|
||||||
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
|
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
|
||||||
|
mux.HandleFunc("GET /admin/storage", a.AdminStorage)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new", a.AdminNewStorage)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/contabo", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/sftp", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/smb", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("GET /admin/storage/new/webdav", a.AdminNewStorageProvider)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/s3", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/contabo", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/sftp", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/smb", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/new/webdav", a.AdminCreateStorage)
|
||||||
|
mux.HandleFunc("GET /admin/storage/{backendID}/edit", a.AdminEditStorageForm)
|
||||||
|
mux.HandleFunc("GET /admin/storage/{backendID}/tests", a.AdminStorageTests)
|
||||||
|
mux.HandleFunc("GET /admin/storage/{backendID}/tests.json", a.AdminStorageTestsJSON)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage)
|
||||||
|
mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup)
|
||||||
|
mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails)
|
||||||
|
mux.HandleFunc("POST /admin/storage/jobs/verify", a.AdminVerifyStorageBackends)
|
||||||
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
|
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
|
||||||
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
|
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
|
||||||
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
|
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
|
||||||
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
|
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("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)
|
||||||
@@ -76,6 +108,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", 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)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -33,6 +34,10 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||||
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
|
||||||
return
|
return
|
||||||
@@ -55,6 +60,10 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||||
|
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
|
||||||
return
|
return
|
||||||
@@ -75,6 +84,9 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.validateCSRF(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||||
_ = a.authService.Logout(cookie.Value)
|
_ = a.authService.Logout(cookie.Value)
|
||||||
}
|
}
|
||||||
@@ -111,22 +123,98 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
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) {
|
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
|
a.renderAccount(w, r, http.StatusOK, user, accountData{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserToken mints a new personal access token and renders the account
|
||||||
|
// page with the one-time plaintext shown. The secret is never recoverable after
|
||||||
|
// this response.
|
||||||
|
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
|
||||||
|
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
|
||||||
|
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := a.requireUser(w, r)
|
||||||
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
||||||
|
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
|
||||||
|
tokens, err := a.authService.ListAPITokens(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views := make([]apiTokenView, 0, len(tokens))
|
||||||
|
for _, token := range tokens {
|
||||||
|
lastUsed := "Never"
|
||||||
|
if token.LastUsedAt != nil {
|
||||||
|
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
|
||||||
|
}
|
||||||
|
views = append(views, apiTokenView{
|
||||||
|
ID: token.ID,
|
||||||
|
Name: token.Name,
|
||||||
|
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
|
||||||
|
LastUsedAt: lastUsed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
data.ID = user.ID
|
||||||
|
data.Email = user.Email
|
||||||
|
data.Role = user.Role
|
||||||
|
data.Tokens = views
|
||||||
|
|
||||||
|
a.renderPage(w, r, status, "account.html", web.PageData{
|
||||||
Title: "Account settings",
|
Title: "Account settings",
|
||||||
Description: "Manage your Warpbox account.",
|
Description: "Manage your Warpbox account.",
|
||||||
CurrentUser: a.authService.PublicUser(user),
|
CurrentUser: a.authService.PublicUser(user),
|
||||||
Data: user,
|
Data: data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -163,12 +251,32 @@ func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) currentUser(r *http.Request) (services.User, bool) {
|
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)
|
cookie, err := r.Cookie(userSessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services.User{}, false
|
return services.User{}, false, nil
|
||||||
}
|
}
|
||||||
user, _, err := a.authService.UserForSession(cookie.Value)
|
user, _, err := a.authService.UserForSession(cookie.Value)
|
||||||
return user, err == nil
|
if err != nil {
|
||||||
|
return services.User{}, false, nil
|
||||||
|
}
|
||||||
|
return user, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -119,7 +119,7 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -135,7 +135,7 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
@@ -156,7 +156,7 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := a.requireUser(w, r)
|
user, ok := a.requireUser(w, r)
|
||||||
if !ok {
|
if !ok || !a.validateCSRF(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -74,9 +76,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST")
|
||||||
|
title := "Shared files on Warpbox"
|
||||||
|
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
||||||
|
if locked && box.Obfuscate {
|
||||||
|
title = "Protected Warpbox link"
|
||||||
|
description = "This shared box is password protected."
|
||||||
|
}
|
||||||
|
|
||||||
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
||||||
Title: "Download files",
|
Title: title,
|
||||||
Description: "Download files shared through Warpbox.",
|
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,11 +96,18 @@ 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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -139,16 +157,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) {
|
||||||
@@ -199,31 +233,40 @@ 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 {
|
||||||
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 {
|
||||||
|
|||||||
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,17 +2,25 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"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
|
LimitSummary string
|
||||||
Collections []collectionView
|
Collections []collectionView
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
AnonymousOpen 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) {
|
||||||
@@ -39,20 +47,92 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
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{
|
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,
|
CurrentUser: currentUser,
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
MaxUploadSize: maxUploadSize,
|
MaxUploadSize: maxUploadSize,
|
||||||
LimitSummary: limitSummary,
|
LimitSummary: limitSummary,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
AnonymousOpen: settings.AnonymousUploadsEnabled,
|
AnonymousOpen: settings.AnonymousUploadsEnabled,
|
||||||
|
ExpiryOptions: expiryOptions,
|
||||||
|
DefaultExpiryMinutes: defaultExpiry,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// homeExpiryOptions builds the expiry ladder offered on the upload form, capped to
|
||||||
|
// the viewer's effective maximum retention. Admins have no cap (the dropdown is
|
||||||
|
// still capped at 365 days for sanity; the API accepts any value for admins).
|
||||||
|
func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) ([]expiryOption, int) {
|
||||||
|
maxDays := settings.AnonymousMaxDays
|
||||||
|
unlimited := false
|
||||||
|
switch {
|
||||||
|
case isAdmin:
|
||||||
|
unlimited = true
|
||||||
|
case loggedIn:
|
||||||
|
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
|
||||||
|
}
|
||||||
|
return buildExpiryOptions(maxDays, unlimited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
|
||||||
|
ladder := []int{60, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
|
||||||
|
|
||||||
|
capMinutes := maxDays * 24 * 60
|
||||||
|
if unlimited || capMinutes <= 0 {
|
||||||
|
capMinutes = 525600
|
||||||
|
}
|
||||||
|
|
||||||
|
options := make([]expiryOption, 0, len(ladder)+1)
|
||||||
|
seen := make(map[int]bool)
|
||||||
|
for _, minutes := range ladder {
|
||||||
|
if minutes > capMinutes {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
options = append(options, expiryOption{Minutes: minutes, Label: expiryLabel(minutes)})
|
||||||
|
seen[minutes] = true
|
||||||
|
}
|
||||||
|
// Always offer the exact cap as a final choice (e.g. a 15-day limit).
|
||||||
|
if !unlimited && !seen[capMinutes] {
|
||||||
|
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
|
||||||
|
}
|
||||||
|
if len(options) == 0 {
|
||||||
|
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 24h when available, otherwise the smallest option offered.
|
||||||
|
defaultMinutes := options[0].Minutes
|
||||||
|
if seen[1440] {
|
||||||
|
defaultMinutes = 1440
|
||||||
|
}
|
||||||
|
return options, defaultMinutes
|
||||||
|
}
|
||||||
|
|
||||||
|
func expiryLabel(minutes int) string {
|
||||||
|
switch {
|
||||||
|
case minutes < 60:
|
||||||
|
return strconv.Itoa(minutes) + " minutes"
|
||||||
|
case minutes < 1440:
|
||||||
|
hours := minutes / 60
|
||||||
|
if hours == 1 {
|
||||||
|
return "1 hour"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(hours) + " hours"
|
||||||
|
case minutes == 1440:
|
||||||
|
return "24 hours"
|
||||||
|
default:
|
||||||
|
days := minutes / 1440
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(days) + " days"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
|
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
return "No file size limit", "Admin uploads bypass storage and daily caps."
|
return "No file size limit", "Admin uploads bypass storage and daily caps."
|
||||||
@@ -61,11 +141,18 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
|
|||||||
if !settings.AnonymousUploadsEnabled {
|
if !settings.AnonymousUploadsEnabled {
|
||||||
return "Anonymous uploads disabled", "Sign in to upload files."
|
return "Anonymous uploads disabled", "Sign in to upload files."
|
||||||
}
|
}
|
||||||
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP."
|
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
|
||||||
}
|
}
|
||||||
quotaMB := settings.DefaultUserStorageMB
|
policy := a.settingsService.EffectivePolicyForUser(settings, user)
|
||||||
if user.StorageQuotaMB != nil {
|
maxUpload := a.uploadService.MaxUploadSizeLabel()
|
||||||
quotaMB = *user.StorageQuotaMB
|
if policy.MaxUploadMB < 0 {
|
||||||
|
maxUpload = "unlimited"
|
||||||
|
} else if policy.MaxUploadMB > 0 {
|
||||||
|
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
|
||||||
}
|
}
|
||||||
return a.uploadService.MaxUploadSizeLabel(), "Daily cap: " + services.FormatMegabytesLabel(settings.UserDailyUploadMB) + " · Storage quota: " + services.FormatMegabytesLabel(quotaMB) + "."
|
quota := "unlimited"
|
||||||
|
if policy.StorageQuotaSet {
|
||||||
|
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
|
||||||
|
}
|
||||||
|
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max."
|
||||||
}
|
}
|
||||||
|
|||||||
78
backend/libs/handlers/security.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -15,7 +16,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
user, loggedIn := a.currentUser(r)
|
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||||
|
if authErr != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||||
settings, err := a.settingsService.UploadPolicy()
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,13 +32,21 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
||||||
if !isAdminUpload {
|
rateKey := uploadRateKey(r, user, loggedIn)
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize()))
|
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
parseLimit := uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize())
|
|
||||||
if isAdminUpload {
|
if isAdminUpload {
|
||||||
parseLimit = 32 << 20
|
parseLimit = 32 << 20
|
||||||
|
} else if parseLimit <= 0 {
|
||||||
|
parseLimit = 32 << 20
|
||||||
}
|
}
|
||||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||||
@@ -53,19 +66,35 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isAdminUpload {
|
if !isAdminUpload {
|
||||||
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, files, totalBytes); message != "" {
|
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
||||||
helpers.WriteJSONError(w, status, message)
|
helpers.WriteJSONError(w, status, message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maxDays := parseInt(r.FormValue("max_days"))
|
||||||
|
if maxDays <= 0 {
|
||||||
|
maxDays = min(7, effectivePolicy.MaxDays)
|
||||||
|
}
|
||||||
|
if !isAdminUpload && maxDays > effectivePolicy.MaxDays {
|
||||||
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresMinutes := parseInt(r.FormValue("expires_minutes"))
|
||||||
|
if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
||||||
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||||
|
return
|
||||||
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
||||||
MaxDays: parseInt(r.FormValue("max_days")),
|
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,
|
OwnerID: ownerID,
|
||||||
CollectionID: collectionID,
|
CollectionID: collectionID,
|
||||||
SkipSizeLimit: isAdminUpload,
|
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
|
||||||
|
CreatorIP: uploadClientIP(r),
|
||||||
|
StorageBackendID: effectivePolicy.StorageBackendID,
|
||||||
})
|
})
|
||||||
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, "error", err.Error())
|
||||||
@@ -73,7 +102,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isAdminUpload {
|
if !isAdminUpload {
|
||||||
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes); err != nil {
|
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
|
||||||
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
|
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 {
|
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
||||||
@@ -92,25 +121,40 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = fmt.Fprintln(w, result.BoxURL)
|
_, _ = fmt.Fprintln(w, result.BoxURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
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 {
|
if len(files) == 0 {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if !loggedIn {
|
if policy.MaxUploadMB > 0 {
|
||||||
anonymousMaxBytes := services.MegabytesToBytes(settings.AnonymousMaxUploadMB)
|
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if file.Size > anonymousMaxBytes {
|
if file.Size > maxBytes {
|
||||||
return http.StatusRequestEntityTooLarge, "file exceeds anonymous upload size limit"
|
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if !loggedIn {
|
||||||
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
|
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, "upload usage could not be checked"
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||||
}
|
}
|
||||||
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.AnonymousDailyUploadMB) {
|
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||||
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
|
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
|
||||||
}
|
}
|
||||||
|
if usage.UploadedBoxes+1 > policy.DailyBoxes {
|
||||||
|
return http.StatusTooManyRequests, "anonymous daily box limit reached"
|
||||||
|
}
|
||||||
|
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "active box limit could not be checked"
|
||||||
|
}
|
||||||
|
if activeBoxes+1 > policy.ActiveBoxes {
|
||||||
|
return http.StatusTooManyRequests, "anonymous active box limit reached"
|
||||||
|
}
|
||||||
|
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
||||||
|
return status, message
|
||||||
|
}
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,42 +162,89 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, "upload usage could not be checked"
|
return http.StatusInternalServerError, "upload usage could not be checked"
|
||||||
}
|
}
|
||||||
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.UserDailyUploadMB) {
|
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
|
||||||
return http.StatusTooManyRequests, "daily upload limit reached"
|
return http.StatusTooManyRequests, "daily upload limit reached"
|
||||||
}
|
}
|
||||||
|
if usage.UploadedBoxes+1 > policy.DailyBoxes {
|
||||||
|
return http.StatusTooManyRequests, "daily box limit reached"
|
||||||
|
}
|
||||||
|
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "active box limit could not be checked"
|
||||||
|
}
|
||||||
|
if activeBoxes+1 > policy.ActiveBoxes {
|
||||||
|
return http.StatusTooManyRequests, "active box limit reached"
|
||||||
|
}
|
||||||
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
|
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return http.StatusInternalServerError, "storage quota could not be checked"
|
return http.StatusInternalServerError, "storage quota could not be checked"
|
||||||
}
|
}
|
||||||
quotaMB := settings.DefaultUserStorageMB
|
if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
|
||||||
if user.StorageQuotaMB != nil {
|
|
||||||
quotaMB = *user.StorageQuotaMB
|
|
||||||
}
|
|
||||||
if activeStorage+totalBytes > services.MegabytesToBytes(quotaMB) {
|
|
||||||
return http.StatusRequestEntityTooLarge, "storage quota reached"
|
return http.StatusRequestEntityTooLarge, "storage quota reached"
|
||||||
}
|
}
|
||||||
|
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
|
||||||
|
return status, message
|
||||||
|
}
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64) error {
|
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if loggedIn {
|
if loggedIn {
|
||||||
return a.settingsService.AddUsage("user", user.ID, totalBytes, now)
|
return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
|
||||||
}
|
}
|
||||||
return a.settingsService.AddUsage("ip", uploadClientIP(r), totalBytes, now)
|
return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadParseLimit(settings services.UploadPolicySettings, loggedIn bool, fallback int64) int64 {
|
func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
|
||||||
if loggedIn {
|
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
|
return fallback * 8
|
||||||
}
|
}
|
||||||
return services.MegabytesToBytes(settings.AnonymousMaxUploadMB) * 8
|
if policy.MaxUploadMB > 0 {
|
||||||
|
return services.MegabytesToBytes(policy.MaxUploadMB) * 8
|
||||||
|
}
|
||||||
|
return fallback * 8
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadClientIP(r *http.Request) string {
|
func uploadClientIP(r *http.Request) string {
|
||||||
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
||||||
var total int64
|
var total int64
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ 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,
|
||||||
@@ -197,7 +198,7 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
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)
|
||||||
@@ -255,6 +256,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RunCleanupNow(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||||
|
return cleanupUnavailableBoxes(uploadService, logger)
|
||||||
|
}
|
||||||
|
|
||||||
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||||
boxes, err := uploadService.ListBoxes(0)
|
boxes, err := uploadService.ListBoxes(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ var (
|
|||||||
sessionsBucket = []byte("sessions")
|
sessionsBucket = []byte("sessions")
|
||||||
invitesBucket = []byte("invites")
|
invitesBucket = []byte("invites")
|
||||||
collectionsBucket = []byte("collections")
|
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 (
|
const (
|
||||||
@@ -48,15 +57,27 @@ type AuthService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
PasswordHash string `json:"passwordHash"`
|
PasswordHash string `json:"passwordHash"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
|
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Policy UserPolicy `json:"policy,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
type PublicUser struct {
|
||||||
@@ -66,6 +87,7 @@ type PublicUser struct {
|
|||||||
Role string
|
Role string
|
||||||
Status string
|
Status string
|
||||||
StorageQuotaMB *float64
|
StorageQuotaMB *float64
|
||||||
|
Policy UserPolicy
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +120,23 @@ type Collection struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
type InviteResult struct {
|
||||||
Invite Invite
|
Invite Invite
|
||||||
URL string
|
URL string
|
||||||
@@ -107,7 +146,7 @@ type InviteResult struct {
|
|||||||
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
|
||||||
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
|
||||||
err := db.Update(func(tx *bbolt.Tx) error {
|
err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} {
|
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} {
|
||||||
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -212,6 +251,131 @@ func (s *AuthService) Logout(raw string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateAPIToken mints a new personal access token for the user. The returned
|
||||||
|
// plaintext is the only time the secret is available; only its hash is stored.
|
||||||
|
func (s *AuthService) CreateAPIToken(userID, name string) (APITokenResult, error) {
|
||||||
|
if userID == "" {
|
||||||
|
return APITokenResult{}, fmt.Errorf("user is required")
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
name = "Untitled token"
|
||||||
|
}
|
||||||
|
if len(name) > 80 {
|
||||||
|
name = name[:80]
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := randomID(32)
|
||||||
|
token := APIToken{
|
||||||
|
ID: randomID(12),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
TokenHash: apiTokenHash(secret),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
if err := s.saveAPIToken(token); err != nil {
|
||||||
|
return APITokenResult{}, err
|
||||||
|
}
|
||||||
|
plaintext := apiTokenPrefix + token.ID + "." + secret
|
||||||
|
return APITokenResult{Token: token, Plaintext: plaintext}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPITokens returns the user's tokens, newest first.
|
||||||
|
func (s *AuthService) ListAPITokens(userID string) ([]APIToken, error) {
|
||||||
|
tokens := make([]APIToken, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(apiTokensBucket).ForEach(func(_, data []byte) error {
|
||||||
|
var token APIToken
|
||||||
|
if err := json.Unmarshal(data, &token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if token.UserID == userID {
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sort.Slice(tokens, func(i, j int) bool {
|
||||||
|
return tokens[i].CreatedAt.After(tokens[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAPIToken removes a token, but only if it belongs to the given user.
|
||||||
|
func (s *AuthService) DeleteAPIToken(userID, tokenID string) error {
|
||||||
|
if userID == "" || tokenID == "" {
|
||||||
|
return ErrTokenNotFound
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(apiTokensBucket)
|
||||||
|
data := bucket.Get([]byte(tokenID))
|
||||||
|
if data == nil {
|
||||||
|
return ErrTokenNotFound
|
||||||
|
}
|
||||||
|
var token APIToken
|
||||||
|
if err := json.Unmarshal(data, &token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if token.UserID != userID {
|
||||||
|
return ErrTokenNotFound
|
||||||
|
}
|
||||||
|
return bucket.Delete([]byte(tokenID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserForAPIToken resolves a raw bearer token to its owning user. It records
|
||||||
|
// last-used time on a best-effort basis. The user must exist and be enabled.
|
||||||
|
func (s *AuthService) UserForAPIToken(raw string) (User, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, apiTokenPrefix)
|
||||||
|
tokenID, secret, ok := strings.Cut(raw, ".")
|
||||||
|
if !ok || tokenID == "" || secret == "" {
|
||||||
|
return User{}, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
var token APIToken
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(apiTokensBucket).Get([]byte(tokenID))
|
||||||
|
if data == nil {
|
||||||
|
return ErrTokenInvalid
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &token)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return User{}, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(apiTokenHash(secret)), []byte(token.TokenHash)) != 1 {
|
||||||
|
return User{}, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := s.UserByID(token.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
if user.Status != UserStatusActive {
|
||||||
|
return User{}, ErrUserDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
token.LastUsedAt = &now
|
||||||
|
_ = s.saveAPIToken(token)
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) saveAPIToken(token APIToken) error {
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
data, err := json.Marshal(token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(apiTokensBucket).Put([]byte(token.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
|
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
|
||||||
email, err := normalizeEmail(email)
|
email, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -381,6 +545,97 @@ func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error
|
|||||||
return s.saveUser(user)
|
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) 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) {
|
func (s *AuthService) UserByID(id string) (User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
@@ -476,6 +731,7 @@ func (s *AuthService) PublicUser(user User) PublicUser {
|
|||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
Status: user.Status,
|
Status: user.Status,
|
||||||
StorageQuotaMB: user.StorageQuotaMB,
|
StorageQuotaMB: user.StorageQuotaMB,
|
||||||
|
Policy: user.Policy,
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,6 +824,11 @@ func tokenHash(token string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
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 {
|
func HashPassword(password string) string {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
@@ -593,3 +854,28 @@ func VerifyPasswordHash(encoded, password string) bool {
|
|||||||
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
|
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
|
||||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
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 {
|
||||||
|
return fmt.Errorf("storage quota override cannot be negative")
|
||||||
|
}
|
||||||
|
if policy.MaxDays != nil && *policy.MaxDays <= 0 {
|
||||||
|
return fmt.Errorf("expiration override must be positive")
|
||||||
|
}
|
||||||
|
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 {
|
||||||
|
return fmt.Errorf("daily box override must be positive")
|
||||||
|
}
|
||||||
|
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 {
|
||||||
|
return fmt.Errorf("active box override must be positive")
|
||||||
|
}
|
||||||
|
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 {
|
||||||
|
return fmt.Errorf("short-window request override must be positive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -103,6 +104,127 @@ func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAPITokenLifecycle(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := auth.CreateAPIToken(user.ID, "CLI laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
|
||||||
|
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
|
||||||
|
}
|
||||||
|
// The secret must never be stored in plaintext — only its hash.
|
||||||
|
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
|
||||||
|
t.Fatalf("stored token hash leaks the plaintext secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := auth.UserForAPIToken(result.Plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserForAPIToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if resolved.ID != user.ID {
|
||||||
|
t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := auth.ListAPITokens(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tokens) != 1 {
|
||||||
|
t.Fatalf("token count = %d, want 1", len(tokens))
|
||||||
|
}
|
||||||
|
if tokens[0].Name != "CLI laptop" {
|
||||||
|
t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop")
|
||||||
|
}
|
||||||
|
if tokens[0].LastUsedAt == nil {
|
||||||
|
t.Fatalf("LastUsedAt not recorded after UserForAPIToken")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil {
|
||||||
|
t.Fatalf("UserForAPIToken accepted a tampered token")
|
||||||
|
}
|
||||||
|
if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil {
|
||||||
|
t.Fatalf("UserForAPIToken accepted an unknown token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAPIToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
||||||
|
t.Fatalf("deleted token still resolved")
|
||||||
|
}
|
||||||
|
remaining, err := auth.ListAPITokens(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(remaining) != 0 {
|
||||||
|
t.Fatalf("token count after delete = %d, want 0", len(remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
other, err := auth.AcceptInvite(invite.Token, "other", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AcceptInvite returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := auth.CreateAPIToken(owner.ID, "owner token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
tokens, err := auth.ListAPITokens(owner.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAPITokens returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another user cannot delete tokens they do not own.
|
||||||
|
if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil {
|
||||||
|
t.Fatalf("DeleteAPIToken allowed deletion across users")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A disabled owner cannot authenticate with their token.
|
||||||
|
if err := auth.DisableUser(owner.ID, true); err != nil {
|
||||||
|
t.Fatalf("DisableUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
|
||||||
|
t.Fatalf("disabled user token still resolved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) {
|
||||||
|
auth := newTestAuthService(t)
|
||||||
|
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlimited := -1.0
|
||||||
|
if err := auth.SetUserPolicy(user.ID, UserPolicy{MaxUploadMB: &unlimited, DailyUploadMB: &unlimited}); err != nil {
|
||||||
|
t.Fatalf("SetUserPolicy rejected -1 unlimited upload limits: %v", err)
|
||||||
|
}
|
||||||
|
updated, err := auth.UserByID(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UserByID returned error: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Policy.MaxUploadMB == nil || *updated.Policy.MaxUploadMB != -1 || updated.Policy.DailyUploadMB == nil || *updated.Policy.DailyUploadMB != -1 {
|
||||||
|
t.Fatalf("unlimited policy was not persisted: %+v", updated.Policy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newTestAuthService(t *testing.T) *AuthService {
|
func newTestAuthService(t *testing.T) *AuthService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,6 +27,17 @@ type UploadPolicySettings struct {
|
|||||||
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
|
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
|
||||||
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
|
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
|
||||||
UsageRetentionDays int `json:"usageRetentionDays"`
|
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 {
|
type UsageRecord struct {
|
||||||
@@ -34,9 +46,24 @@ type UsageRecord struct {
|
|||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
UploadedBytes int64 `json:"uploadedBytes"`
|
UploadedBytes int64 `json:"uploadedBytes"`
|
||||||
|
UploadedBoxes int `json:"uploadedBoxes"`
|
||||||
|
RequestCount int `json:"requestCount"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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 {
|
type SettingsService struct {
|
||||||
db *bbolt.DB
|
db *bbolt.DB
|
||||||
defaults UploadPolicySettings
|
defaults UploadPolicySettings
|
||||||
@@ -52,8 +79,20 @@ func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*Settin
|
|||||||
UserDailyUploadMB: defaults.UserDailyUploadMB,
|
UserDailyUploadMB: defaults.UserDailyUploadMB,
|
||||||
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
|
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
|
||||||
UsageRetentionDays: defaults.UsageRetentionDays,
|
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 {
|
if err := service.validate(service.defaults); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -71,6 +110,43 @@ func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*Settin
|
|||||||
return service, nil
|
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) {
|
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
||||||
settings := s.defaults
|
settings := s.defaults
|
||||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
@@ -78,7 +154,11 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return json.Unmarshal(data, &settings)
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings = s.withDefaultGaps(settings)
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UploadPolicySettings{}, err
|
return UploadPolicySettings{}, err
|
||||||
@@ -89,6 +169,58 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
|||||||
return settings, nil
|
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 {
|
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
|
||||||
if err := s.validate(settings); err != nil {
|
if err := s.validate(settings); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -117,7 +249,17 @@ func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (Usa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
|
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 {
|
if bytes <= 0 {
|
||||||
|
bytes = 0
|
||||||
|
}
|
||||||
|
if boxes < 0 {
|
||||||
|
boxes = 0
|
||||||
|
}
|
||||||
|
if bytes == 0 && boxes == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
key := usageKey(subjectType, subject, now)
|
key := usageKey(subjectType, subject, now)
|
||||||
@@ -131,6 +273,7 @@ func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
record.UploadedBytes += bytes
|
record.UploadedBytes += bytes
|
||||||
|
record.UploadedBoxes += boxes
|
||||||
record.UpdatedAt = now.UTC()
|
record.UpdatedAt = now.UTC()
|
||||||
next, err := json.Marshal(record)
|
next, err := json.Marshal(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,6 +283,63 @@ func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
|
||||||
if retentionDays <= 0 {
|
if retentionDays <= 0 {
|
||||||
return fmt.Errorf("usage retention days must be positive")
|
return fmt.Errorf("usage retention days must be positive")
|
||||||
@@ -170,14 +370,14 @@ func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
||||||
if settings.AnonymousMaxUploadMB <= 0 {
|
if settings.AnonymousMaxUploadMB < 0 && settings.AnonymousMaxUploadMB != -1 || settings.AnonymousMaxUploadMB == 0 {
|
||||||
return fmt.Errorf("anonymous max upload must be positive")
|
return fmt.Errorf("anonymous max upload must be positive or -1 for unlimited")
|
||||||
}
|
}
|
||||||
if settings.AnonymousDailyUploadMB <= 0 {
|
if settings.AnonymousDailyUploadMB < 0 && settings.AnonymousDailyUploadMB != -1 || settings.AnonymousDailyUploadMB == 0 {
|
||||||
return fmt.Errorf("anonymous daily upload must be positive")
|
return fmt.Errorf("anonymous daily upload must be positive or -1 for unlimited")
|
||||||
}
|
}
|
||||||
if settings.UserDailyUploadMB <= 0 {
|
if settings.UserDailyUploadMB < 0 && settings.UserDailyUploadMB != -1 || settings.UserDailyUploadMB == 0 {
|
||||||
return fmt.Errorf("user daily upload must be positive")
|
return fmt.Errorf("user daily upload must be positive or -1 for unlimited")
|
||||||
}
|
}
|
||||||
if settings.DefaultUserStorageMB <= 0 {
|
if settings.DefaultUserStorageMB <= 0 {
|
||||||
return fmt.Errorf("default user storage must be positive")
|
return fmt.Errorf("default user storage must be positive")
|
||||||
@@ -185,6 +385,21 @@ func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
|||||||
if settings.UsageRetentionDays <= 0 {
|
if settings.UsageRetentionDays <= 0 {
|
||||||
return fmt.Errorf("usage retention days must be positive")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,16 +422,50 @@ func ParseMegabytesValue(value string) (float64, error) {
|
|||||||
return parsed, nil
|
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 {
|
func MegabytesToBytes(value float64) int64 {
|
||||||
return int64(value * 1024 * 1024)
|
return int64(value * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GigabytesToBytes(value float64) int64 {
|
||||||
|
return int64(value * 1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
func FormatMegabytesFromBytes(value int64) string {
|
func FormatMegabytesFromBytes(value int64) string {
|
||||||
mb := float64(value) / 1024 / 1024
|
mb := float64(value) / 1024 / 1024
|
||||||
|
mb = math.Round(mb*100) / 100
|
||||||
return FormatMegabytesLabel(mb)
|
return FormatMegabytesLabel(mb)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatMegabytesLabel(value float64) string {
|
func FormatMegabytesLabel(value float64) string {
|
||||||
|
if value < 0 {
|
||||||
|
return "unlimited"
|
||||||
|
}
|
||||||
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +477,14 @@ func usageDate(now time.Time) string {
|
|||||||
return now.UTC().Format("2006-01-02")
|
return now.UTC().Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBackendID(id string) string {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return StorageBackendLocal
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func ClientIP(remoteAddr, forwardedFor string) string {
|
func ClientIP(remoteAddr, forwardedFor string) string {
|
||||||
if forwardedFor != "" {
|
if forwardedFor != "" {
|
||||||
parts := strings.Split(forwardedFor, ",")
|
parts := strings.Split(forwardedFor, ",")
|
||||||
|
|||||||
@@ -117,6 +117,30 @@ func TestSettingsRejectInvalidMegabytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) {
|
||||||
|
settings := newTestSettingsService(t)
|
||||||
|
policy, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
policy.AnonymousMaxUploadMB = -1
|
||||||
|
policy.AnonymousDailyUploadMB = -1
|
||||||
|
policy.UserDailyUploadMB = -1
|
||||||
|
if err := settings.UpdateUploadPolicy(policy); err != nil {
|
||||||
|
t.Fatalf("UpdateUploadPolicy rejected -1 unlimited upload limits: %v", err)
|
||||||
|
}
|
||||||
|
next, err := settings.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||||
|
}
|
||||||
|
if next.AnonymousMaxUploadMB != -1 || next.AnonymousDailyUploadMB != -1 || next.UserDailyUploadMB != -1 {
|
||||||
|
t.Fatalf("unlimited upload limits were not persisted: %+v", next)
|
||||||
|
}
|
||||||
|
if got := FormatMegabytesLabel(-1); got != "unlimited" {
|
||||||
|
t.Fatalf("FormatMegabytesLabel(-1) = %q, want unlimited", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDailyUsageAndCleanup(t *testing.T) {
|
func TestDailyUsageAndCleanup(t *testing.T) {
|
||||||
settings := newTestSettingsService(t)
|
settings := newTestSettingsService(t)
|
||||||
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
@@ -146,6 +170,44 @@ func TestDailyUsageAndCleanup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func newTestSettingsService(t *testing.T) *SettingsService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
527
backend/libs/services/storage.go
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
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
|
||||||
|
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) 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) DisableBackend(id string, inUse bool) error {
|
||||||
|
if id == "" || id == StorageBackendLocal {
|
||||||
|
return fmt.Errorf("local storage cannot be disabled")
|
||||||
|
}
|
||||||
|
if inUse {
|
||||||
|
return fmt.Errorf("storage backend is in use")
|
||||||
|
}
|
||||||
|
cfg, err := s.BackendConfig(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg.Enabled = false
|
||||||
|
return s.SaveBackendConfig(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +14,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -31,43 +34,51 @@ 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
|
OwnerID string
|
||||||
CollectionID string
|
CollectionID string
|
||||||
SkipSizeLimit bool
|
SkipSizeLimit bool
|
||||||
|
CreatorIP string
|
||||||
|
StorageBackendID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Box struct {
|
type Box struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OwnerID string `json:"ownerId,omitempty"`
|
OwnerID string `json:"ownerId,omitempty"`
|
||||||
CollectionID string `json:"collectionId,omitempty"`
|
CollectionID string `json:"collectionId,omitempty"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
ExpiresAt time.Time `json:"expiresAt"`
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
MaxDownloads int `json:"maxDownloads"`
|
MaxDownloads int `json:"maxDownloads"`
|
||||||
DownloadCount int `json:"downloadCount"`
|
DownloadCount int `json:"downloadCount"`
|
||||||
PasswordSalt string `json:"passwordSalt,omitempty"`
|
PasswordSalt string `json:"passwordSalt,omitempty"`
|
||||||
PasswordHash string `json:"passwordHash,omitempty"`
|
PasswordHash string `json:"passwordHash,omitempty"`
|
||||||
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
|
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
|
||||||
Obfuscate bool `json:"obfuscate"`
|
Obfuscate bool `json:"obfuscate"`
|
||||||
Files []File `json:"files"`
|
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 {
|
||||||
@@ -121,9 +132,6 @@ type UserBox struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -140,6 +148,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,
|
||||||
@@ -148,6 +161,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +181,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())
|
||||||
@@ -182,15 +200,23 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
opts.MaxDays = 7
|
opts.MaxDays = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour)
|
||||||
|
if opts.ExpiresInMinutes > 0 {
|
||||||
|
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
box := Box{
|
box := Box{
|
||||||
ID: randomID(10),
|
ID: randomID(10),
|
||||||
OwnerID: strings.TrimSpace(opts.OwnerID),
|
OwnerID: strings.TrimSpace(opts.OwnerID),
|
||||||
CollectionID: strings.TrimSpace(opts.CollectionID),
|
CollectionID: strings.TrimSpace(opts.CollectionID),
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatorIP: strings.TrimSpace(opts.CreatorIP),
|
||||||
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
|
StorageBackendID: normalizeBackendID(opts.StorageBackendID),
|
||||||
MaxDownloads: opts.MaxDownloads,
|
CreatedAt: now,
|
||||||
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
|
ExpiresAt: expiresAt,
|
||||||
Files: make([]File, 0, len(files)),
|
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)
|
||||||
@@ -200,8 +226,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
box.PasswordHash = hash
|
box.PasswordHash = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
boxDir := filepath.Join(s.filesDir, box.ID)
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||||
if err := os.MkdirAll(boxDir, 0o755); err != nil {
|
if err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,13 +250,18 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
|
|
||||||
fileID := randomID(8)
|
fileID := randomID(8)
|
||||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
||||||
storedPath := filepath.Join(boxDir, storedName)
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
contentType := header.Header.Get("Content-Type")
|
contentType := header.Header.Get("Content-Type")
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = "application/octet-stream"
|
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 := writeUploadedFile(storedPath, file, maxSize); err != nil {
|
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
@@ -243,6 +274,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
Size: header.Size,
|
Size: header.Size,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
PreviewKind: previewKind(contentType),
|
PreviewKind: previewKind(contentType),
|
||||||
|
ObjectKey: objectKey,
|
||||||
UploadedAt: time.Now().UTC(),
|
UploadedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -296,6 +328,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 {
|
||||||
@@ -463,13 +518,22 @@ 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
|
if backend, err := s.storage.Backend(s.BoxStorageBackendID(box)); 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
|
||||||
@@ -499,6 +563,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 != ""
|
||||||
}
|
}
|
||||||
@@ -564,11 +678,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,
|
||||||
@@ -592,6 +706,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
|
||||||
@@ -654,6 +771,27 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
|
|||||||
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 {
|
||||||
@@ -691,10 +829,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"
|
||||||
@@ -93,6 +94,276 @@ func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,23 +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
|
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
|
||||||
@@ -55,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/backgrounds/stars1.gif
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
525
backend/static/css/00-base.css
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--background: #0b0b16;
|
||||||
|
--foreground: #f5f3ff;
|
||||||
|
--card: #15132b;
|
||||||
|
--card-foreground: #f5f3ff;
|
||||||
|
--muted: #1e1b3a;
|
||||||
|
--muted-foreground: #a8a4cf;
|
||||||
|
--accent: #2a2550;
|
||||||
|
--accent-foreground: #f5f3ff;
|
||||||
|
--border: rgba(168, 150, 255, 0.16);
|
||||||
|
--input: rgba(168, 150, 255, 0.22);
|
||||||
|
--primary: #8b5cf6;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--primary-hover: #7c3aed;
|
||||||
|
--ring: #a78bfa;
|
||||||
|
--success: #5eead4;
|
||||||
|
--danger: #fb7185;
|
||||||
|
--radius: 0.875rem;
|
||||||
|
--shadow: 0 24px 70px rgba(8, 4, 32, 0.6);
|
||||||
|
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--header-bg: rgba(11, 11, 22, 0.68);
|
||||||
|
--body-bg:
|
||||||
|
radial-gradient(circle at 50% -10%, rgba(139, 92, 246, 0.18), transparent 34rem),
|
||||||
|
linear-gradient(180deg, #0b0b16 0%, #0a0918 100%);
|
||||||
|
--surface-1: rgba(139, 92, 246, 0.07);
|
||||||
|
--surface-1-hover: rgba(139, 92, 246, 0.14);
|
||||||
|
--surface-2: rgba(139, 92, 246, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="classic"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--background: #09090b;
|
||||||
|
--foreground: #fafafa;
|
||||||
|
--card: #18181b;
|
||||||
|
--card-foreground: #fafafa;
|
||||||
|
--muted: #27272a;
|
||||||
|
--muted-foreground: #a1a1aa;
|
||||||
|
--accent: #27272a;
|
||||||
|
--accent-foreground: #fafafa;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--input: rgba(255, 255, 255, 0.15);
|
||||||
|
--primary: #f4f4f5;
|
||||||
|
--primary-foreground: #18181b;
|
||||||
|
--primary-hover: #e4e4e7;
|
||||||
|
--ring: #71717a;
|
||||||
|
--success: #86efac;
|
||||||
|
--danger: #fca5a5;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
|
||||||
|
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--header-bg: rgba(9, 9, 11, 0.84);
|
||||||
|
--body-bg:
|
||||||
|
radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem),
|
||||||
|
linear-gradient(180deg, #09090b 0%, #0f0f12 100%);
|
||||||
|
--surface-1: rgba(39, 39, 42, 0.42);
|
||||||
|
--surface-1-hover: rgba(39, 39, 42, 0.68);
|
||||||
|
--surface-2: rgba(39, 39, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #000000;
|
||||||
|
--card: #c0c0c0;
|
||||||
|
--card-foreground: #000000;
|
||||||
|
--muted: #c0c0c0;
|
||||||
|
--muted-foreground: #404040;
|
||||||
|
--accent: #000078;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
--border: #000000;
|
||||||
|
--input: #000000;
|
||||||
|
--primary: #000078;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--primary-hover: #0f80cd;
|
||||||
|
--ring: #000078;
|
||||||
|
--success: #008000;
|
||||||
|
--danger: #c00000;
|
||||||
|
--radius: 0rem;
|
||||||
|
--shadow:
|
||||||
|
inset -1px -1px 0 #404040,
|
||||||
|
inset 1px 1px 0 #ffffff,
|
||||||
|
inset -2px -2px 0 #808080,
|
||||||
|
inset 2px 2px 0 #dfdfdf;
|
||||||
|
--font-sans: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
--header-bg: #c0c0c0;
|
||||||
|
--body-bg: #000000;
|
||||||
|
--surface-1: #ffffff;
|
||||||
|
--surface-1-hover: #fffff0;
|
||||||
|
--surface-2: #c0c0c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: -4rem;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--header-bg);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
min-height: 3.5rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.nav-links,
|
||||||
|
.footer-links,
|
||||||
|
.inline-form {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 650;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.12;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p,
|
||||||
|
.download-subtitle,
|
||||||
|
.panel-header p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-view {
|
||||||
|
width: min(28rem, calc(100% - 2rem));
|
||||||
|
min-height: calc(100vh - 7.25rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-copy,
|
||||||
|
.auth-alt {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-form label,
|
||||||
|
.inline-controls label,
|
||||||
|
.collection-create label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field input {
|
||||||
|
width: 1rem;
|
||||||
|
min-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field span {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.25rem;
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem);
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer,
|
||||||
|
.result-header {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer p,
|
||||||
|
#result-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
button {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem);
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-outline {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-outline:hover,
|
||||||
|
.button-ghost:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
border-color: rgba(248, 113, 113, 0.28);
|
||||||
|
background: rgba(127, 29, 29, 0.3);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover {
|
||||||
|
background: rgba(127, 29, 29, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-wide {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.8rem 0 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem);
|
||||||
|
background: var(--background);
|
||||||
|
padding: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-picker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-picker > span {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-picker select {
|
||||||
|
width: auto;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: calc(var(--radius) - 0.25rem);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: #fecaca;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-sm {
|
||||||
|
min-height: 1.85rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge variants */
|
||||||
|
.badge-active {
|
||||||
|
background: rgba(134, 239, 172, 0.12);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-disabled {
|
||||||
|
background: rgba(252, 165, 165, 0.1);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-expired {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav username indicator in header */
|
||||||
|
.nav-username {
|
||||||
|
max-width: 8rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
281
backend/static/css/10-layout.css
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
.app-shell {
|
||||||
|
width: min(86rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14rem minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 5rem;
|
||||||
|
align-self: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(24, 24, 27, 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.62rem 0.75rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover,
|
||||||
|
.sidebar-link.is-active {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .app-sidebar {
|
||||||
|
border-color: rgba(125, 211, 252, 0.28);
|
||||||
|
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .sidebar-link.is-active {
|
||||||
|
border-color: rgba(125, 211, 252, 0.42);
|
||||||
|
background: rgba(14, 116, 144, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell .kicker {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout {
|
||||||
|
display: grid;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logout .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-create {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-upload .drop-zone {
|
||||||
|
min-height: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-options {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-tabs,
|
||||||
|
.inline-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-controls input,
|
||||||
|
.inline-controls select {
|
||||||
|
min-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-input {
|
||||||
|
width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-narrow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form .checkbox-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tab navigation */
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 120ms, color 120ms, border-color 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.is-active {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar structure */
|
||||||
|
.sidebar-sep {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collection create dropdown */
|
||||||
|
.new-collection-drop {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-collection-drop > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-collection-drop > summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.new-collection-body {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
z-index: 10;
|
||||||
|
width: 15rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--card) 97%, #000);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-collection-body label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copyable URL field */
|
||||||
|
.copy-field {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-field input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings sections */
|
||||||
|
.settings-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section .checkbox-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quota form in admin users table */
|
||||||
|
.quota-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-form input {
|
||||||
|
width: 6.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
214
backend/static/css/15-revamp.css
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
* Revamp ("Aurora glass") flourishes.
|
||||||
|
*
|
||||||
|
* These rules only apply to the default/revamp theme. They are scoped to
|
||||||
|
* :root:not([data-theme="classic"]):not([data-theme="retro"]) so they cover both the explicit
|
||||||
|
* data-theme="revamp" attribute AND the no-JS default (no attribute), while
|
||||||
|
* never touching the classic theme. Token colours live in 00-base.css; this
|
||||||
|
* file adds the things a flat token swap can't: the animated aurora backdrop,
|
||||||
|
* frosted glass, gradient accents, glow and motion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated aurora backdrop ------------------------------------------------ */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: -20vmax;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(38vmax 38vmax at 18% 12%, rgba(99, 102, 241, 0.38), transparent 60%),
|
||||||
|
radial-gradient(34vmax 34vmax at 82% 18%, rgba(34, 211, 238, 0.26), transparent 60%),
|
||||||
|
radial-gradient(40vmax 40vmax at 70% 88%, rgba(139, 92, 246, 0.34), transparent 62%),
|
||||||
|
radial-gradient(30vmax 30vmax at 12% 82%, rgba(236, 72, 153, 0.22), transparent 60%);
|
||||||
|
filter: blur(8px) saturate(125%);
|
||||||
|
animation: aurora-drift 26s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
/* faint grain/vignette to keep the glow from washing out text */
|
||||||
|
background: radial-gradient(circle at 50% 40%, transparent 0, rgba(10, 9, 24, 0.55) 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora-drift {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(-4%, -2%, 0) rotate(0deg) scale(1.05);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate3d(3%, 2%, 0) rotate(8deg) scale(1.12);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(2%, -3%, 0) rotate(-6deg) scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frosted glass cards ----------------------------------------------------- */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .card {
|
||||||
|
background: linear-gradient(
|
||||||
|
155deg,
|
||||||
|
color-mix(in srgb, var(--card) 78%, transparent),
|
||||||
|
color-mix(in srgb, var(--card) 92%, transparent)
|
||||||
|
);
|
||||||
|
border-color: rgba(168, 150, 255, 0.18);
|
||||||
|
backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky header gets the same glassy treatment */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .site-header {
|
||||||
|
backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand mark glows */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .brand-mark {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings get a soft gradient sheen */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) h1 {
|
||||||
|
background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient primary buttons ------------------------------------------------ */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary,
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button.is-active {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.38);
|
||||||
|
transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
|
||||||
|
filter: brightness(1.08);
|
||||||
|
box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline / ghost buttons get a subtle lift on hover */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline,
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost {
|
||||||
|
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline:hover,
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost:hover {
|
||||||
|
border-color: rgba(168, 150, 255, 0.4);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow focus rings -------------------------------------------------------- */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) :focus-visible {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) input:focus,
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) select:focus {
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop zone: animated, glowing -------------------------------------------- */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone {
|
||||||
|
border-color: rgba(168, 150, 255, 0.3);
|
||||||
|
background:
|
||||||
|
radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%),
|
||||||
|
var(--surface-1);
|
||||||
|
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone:hover,
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging {
|
||||||
|
border-color: #a78bfa;
|
||||||
|
box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-icon {
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging .drop-icon {
|
||||||
|
animation: drop-bounce 700ms ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drop-bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges pick up a tinted glass look */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .badge {
|
||||||
|
background: rgba(139, 92, 246, 0.14);
|
||||||
|
color: #d6ccff;
|
||||||
|
border: 1px solid rgba(168, 150, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File / result rows lift on hover */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item,
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .result-item {
|
||||||
|
background: color-mix(in srgb, var(--card) 60%, transparent);
|
||||||
|
border-color: rgba(168, 150, 255, 0.14);
|
||||||
|
transition: border-color 140ms ease, transform 140ms ease, background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item:hover {
|
||||||
|
border-color: rgba(168, 150, 255, 0.34);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumbnails on the download page */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) .file-emblem {
|
||||||
|
background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18));
|
||||||
|
color: #d6ccff;
|
||||||
|
border: 1px solid rgba(168, 150, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gentle entrance for primary content cards */
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * {
|
||||||
|
animation: rise-in 420ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
442
backend/static/css/16-retro.css
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
/*
|
||||||
|
* "retro" theme flourishes — modelled on danlegt.com.
|
||||||
|
*
|
||||||
|
* Windows 98 chrome over a black pixel-star desktop, PixeloidSans pixel font,
|
||||||
|
* crisp (non-antialiased, pixelated) rendering. Scoped entirely to
|
||||||
|
* :root[data-theme="retro"] so it never touches the other themes.
|
||||||
|
*
|
||||||
|
* CSP-safe: external stylesheet + self-hosted fonts only (font-src 'self'),
|
||||||
|
* no inline styles, no remote assets. The starfield is pure CSS so we don't
|
||||||
|
* depend on img-src for a background gif.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Self-hosted pixel fonts (mirrored locally — GGBotNet PixeloidSans is free,
|
||||||
|
PixelOperator is CC0). ------------------------------------------------- */
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf") format("truetype");
|
||||||
|
font-weight: bold;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixelOperatorMono";
|
||||||
|
src: url("/static/fonts/pixel_operator/PixelOperatorMono.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixelOperatorMono";
|
||||||
|
src: url("/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf") format("truetype");
|
||||||
|
font-weight: bold;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crisp, pixelated, non-smoothed rendering like the source site. */
|
||||||
|
:root[data-theme="retro"] {
|
||||||
|
font-smooth: never;
|
||||||
|
-webkit-font-smoothing: none;
|
||||||
|
-moz-osx-font-smoothing: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] img,
|
||||||
|
:root[data-theme="retro"] .thumb-link img,
|
||||||
|
:root[data-theme="retro"] .preview-stage img {
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Square everything — Win98 had no rounded corners. */
|
||||||
|
:root[data-theme="retro"] *,
|
||||||
|
:root[data-theme="retro"] *::before,
|
||||||
|
:root[data-theme="retro"] *::after {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Black desktop with the tiled starfield mirrored from danlegt.com. */
|
||||||
|
:root[data-theme="retro"] body {
|
||||||
|
background-color: #000000;
|
||||||
|
background-image: url("/static/backgrounds/stars1.gif");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection + focus use the classic dotted/navy treatment. */
|
||||||
|
:root[data-theme="retro"] ::selection {
|
||||||
|
background: #000078;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] :focus-visible {
|
||||||
|
outline: 1px dotted #000000;
|
||||||
|
outline-offset: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header: thin flat silver toolbar with a bottom bevel. */
|
||||||
|
:root[data-theme="retro"] .site-header {
|
||||||
|
background: #c0c0c0;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
border-bottom: 2px solid #404040;
|
||||||
|
box-shadow: inset 0 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .nav {
|
||||||
|
min-height: 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .site-header .button {
|
||||||
|
min-height: 1.6rem;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .brand {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .brand-mark {
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
background: linear-gradient(90deg, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #6f6fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards are raised silver windows with black text. */
|
||||||
|
:root[data-theme="retro"] .card {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings become Win98 active title bars. */
|
||||||
|
:root[data-theme="retro"] h1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: -0.35rem -0.35rem 1rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fake window control button on the right of every title bar. */
|
||||||
|
:root[data-theme="retro"] h1::after {
|
||||||
|
content: "✕";
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1rem;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links: classic blue, underlined, purple when visited. */
|
||||||
|
:root[data-theme="retro"] a:not(.button):not(.brand) {
|
||||||
|
color: #0000ee;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] a:not(.button):not(.brand):visited {
|
||||||
|
color: #551a8b;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] a:not(.button):not(.brand):hover {
|
||||||
|
color: #ee0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons: grey beveled chunks that press in when active. */
|
||||||
|
:root[data-theme="retro"] .button,
|
||||||
|
:root[data-theme="retro"] button {
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .button:hover,
|
||||||
|
:root[data-theme="retro"] button:hover {
|
||||||
|
background: #d4d0c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .button:active,
|
||||||
|
:root[data-theme="retro"] button:active,
|
||||||
|
:root[data-theme="retro"] .button.is-active {
|
||||||
|
background: #c0c0c0;
|
||||||
|
box-shadow: inset 1px 1px 0 #404040, inset -1px -1px 0 #ffffff, inset 2px 2px 0 #808080, inset -2px -2px 0 #dfdfdf;
|
||||||
|
padding-top: calc(0.45rem + 1px);
|
||||||
|
padding-left: calc(0.85rem + 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The primary call-to-action gets the blue title-bar gradient. */
|
||||||
|
:root[data-theme="retro"] .button-primary {
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .button-primary:hover {
|
||||||
|
background: linear-gradient(to right, #0a0a9a, 80%, #1a90dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .button-danger {
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #c00000;
|
||||||
|
border-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs and selects look sunken (inset bevel). */
|
||||||
|
:root[data-theme="retro"] input,
|
||||||
|
:root[data-theme="retro"] select,
|
||||||
|
:root[data-theme="retro"] textarea {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] input:focus,
|
||||||
|
:root[data-theme="retro"] select:focus {
|
||||||
|
outline: 1px dotted #000000;
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels inside windows read black, not muted-grey-on-grey. */
|
||||||
|
:root[data-theme="retro"] label span,
|
||||||
|
:root[data-theme="retro"] .stack-form label,
|
||||||
|
:root[data-theme="retro"] .form-footer p,
|
||||||
|
:root[data-theme="retro"] .drop-copy,
|
||||||
|
:root[data-theme="retro"] .drop-meta,
|
||||||
|
:root[data-theme="retro"] .upload-subtitle,
|
||||||
|
:root[data-theme="retro"] .download-subtitle,
|
||||||
|
:root[data-theme="retro"] .muted-copy,
|
||||||
|
:root[data-theme="retro"] .kicker,
|
||||||
|
:root[data-theme="retro"] .checkbox-field span {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API / docs page: the header is a real full-width window with the intro text
|
||||||
|
inside it, and each section card gets a Win98 title bar from its <h2>. */
|
||||||
|
:root[data-theme="retro"] .docs-header {
|
||||||
|
max-width: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .docs-header .kicker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .docs-header p,
|
||||||
|
:root[data-theme="retro"] .docs-header code {
|
||||||
|
color: #000000;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .docs-card h2,
|
||||||
|
:root[data-theme="retro"] .upload-options .options-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: -1.5rem -1.5rem 1rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make title bars flush to the window edge (a real Win98 title bar) wherever
|
||||||
|
the heading is the top of its window: the upload card, the API header, and
|
||||||
|
the API section cards. Pages where a heading sits below an icon or kicker
|
||||||
|
(download/preview/login) keep the inset heading from the base h1 rule. */
|
||||||
|
:root[data-theme="retro"] .card-content > h1:first-child,
|
||||||
|
:root[data-theme="retro"] .docs-header h1 {
|
||||||
|
margin: -1.5rem -1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .docs-card h2::after,
|
||||||
|
:root[data-theme="retro"] .upload-options .options-title::after {
|
||||||
|
content: "✕";
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1rem;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop zone: a sunken white field with a dashed navy border. */
|
||||||
|
:root[data-theme="retro"] .drop-zone {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px dashed #000078;
|
||||||
|
box-shadow: inset 2px 2px 0 #808080, inset -2px -2px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .drop-zone:hover,
|
||||||
|
:root[data-theme="retro"] .drop-zone.is-dragging {
|
||||||
|
background: #fffff0;
|
||||||
|
border-color: #0f80cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .drop-icon {
|
||||||
|
color: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero "Welcome" badge becomes a high-contrast blinking pixel sticker
|
||||||
|
that sits on the black desktop above the window. */
|
||||||
|
:root[data-theme="retro"] .hero-eyebrow {
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
border: 1px solid #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #6f6fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .hero-eyebrow::before {
|
||||||
|
content: "★ ";
|
||||||
|
color: #ffff66;
|
||||||
|
animation: retro-blink 1.1s steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .hero-eyebrow::after {
|
||||||
|
content: " ★";
|
||||||
|
color: #ffff66;
|
||||||
|
animation: retro-blink 1.1s steps(1, end) 0.55s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes retro-blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root[data-theme="retro"] .hero-eyebrow::before,
|
||||||
|
:root[data-theme="retro"] .hero-eyebrow::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges become square chips. */
|
||||||
|
:root[data-theme="retro"] .badge {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File / result rows: flat white with a sunken hairline. */
|
||||||
|
:root[data-theme="retro"] .download-item,
|
||||||
|
:root[data-theme="retro"] .result-item {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #dfdfdf, inset -1px -1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .file-main,
|
||||||
|
:root[data-theme="retro"] .download-item small,
|
||||||
|
:root[data-theme="retro"] .result-item code {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .file-emblem {
|
||||||
|
background: #000078;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #4a4aff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks use the pixel mono font. */
|
||||||
|
:root[data-theme="retro"] code,
|
||||||
|
:root[data-theme="retro"] pre,
|
||||||
|
:root[data-theme="retro"] pre code {
|
||||||
|
font-family: "PixelOperatorMono", ui-monospace, monospace;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] pre {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar: blocky segmented Win98 look. */
|
||||||
|
:root[data-theme="retro"] .progress {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .progress span {
|
||||||
|
background: repeating-linear-gradient(90deg, #000078 0 8px, transparent 8px 10px), #0f80cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chunky retro scrollbars (WebKit/Blink). */
|
||||||
|
:root[data-theme="retro"] ::-webkit-scrollbar {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] ::-webkit-scrollbar-track {
|
||||||
|
background: #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] ::-webkit-scrollbar-thumb {
|
||||||
|
background: #c0c0c0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer sits on the black desktop: white pixel text + a wink to the old web. */
|
||||||
|
:root[data-theme="retro"] .site-footer {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .site-footer a,
|
||||||
|
:root[data-theme="retro"] .footer-links a:not(.button) {
|
||||||
|
color: #66ccff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .theme-picker > span {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .site-footer::after {
|
||||||
|
content: "✩ Best viewed in 1024×768 ✩";
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
:root[data-theme="retro"] .site-footer::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
363
backend/static/css/20-upload.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
104
backend/static/css/40-docs.css
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
.admin-view {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-view {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header {
|
||||||
|
max-width: 44rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-header p {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-card {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-card p {
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-card-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list,
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div,
|
||||||
|
.field-grid {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 7rem minmax(0, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list dt,
|
||||||
|
.endpoint-list dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list dt,
|
||||||
|
.field-grid span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list dd code {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-steps {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-steps li + li {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
190
backend/static/css/50-admin.css
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
.admin-header,
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(24, 24, 27, 0.78);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span,
|
||||||
|
.table-header p {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-edit-metrics {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-card {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header p {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-actions form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Inline row edit (details/summary in table cells) */
|
||||||
|
.row-edit {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-edit > summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-edit > summary::-webkit-details-marker { display: none; }
|
||||||
|
|
||||||
|
.row-edit[open] > summary {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-edit-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-edit-form input,
|
||||||
|
.row-edit-form select {
|
||||||
|
width: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 8rem;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-edit-form {
|
||||||
|
width: min(34rem, calc(100vw - 2rem));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-edit-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-edit-form label span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-edit-form textarea {
|
||||||
|
min-height: 5rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-edit-form .checkbox-field,
|
||||||
|
.storage-edit-form button {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.storage-edit-form {
|
||||||
|
position: static;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
466
backend/static/css/60-storage.css
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
/* ── Storage card UI ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.storage-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card.is-local {
|
||||||
|
border-left: 3px solid rgba(125, 211, 252, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card.is-editing {
|
||||||
|
border-color: rgba(125, 211, 252, 0.35);
|
||||||
|
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-icon svg {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-usage {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View-mode summary */
|
||||||
|
.storage-card-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 1.75rem;
|
||||||
|
padding: 0.65rem 1.1rem 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-detail > span:first-child,
|
||||||
|
.storage-detail > code:first-child {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-detail > span:last-child,
|
||||||
|
.storage-detail > code:last-child {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-detail-test > span:last-child {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-detail-test.is-ok > span:last-child { color: #86efac; }
|
||||||
|
.storage-detail-test.is-err > span:last-child { color: #fca5a5; }
|
||||||
|
|
||||||
|
/* Edit-mode body */
|
||||||
|
.storage-card:not(.is-editing) .storage-card-body { display: none; }
|
||||||
|
.storage-card.is-editing .storage-card-summary { display: none; }
|
||||||
|
|
||||||
|
.storage-card-body {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-fields label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-fields label span {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-fields textarea {
|
||||||
|
min-height: 5rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-fields .checkbox-field {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-card-edit-bar {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding-top: 0.65rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.storage-card-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-option {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-option:hover {
|
||||||
|
border-color: rgba(125, 211, 252, 0.35);
|
||||||
|
background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-option svg {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-option strong {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-type-option span {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-ops-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-op-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-content: start;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-op-card strong {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-op-card span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-op-card .button {
|
||||||
|
justify-self: start;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-form-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-modal[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-modal-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(30rem, 100%);
|
||||||
|
max-height: min(42rem, 90vh);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: var(--shadow, 0 1rem 2.5rem rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-speed-form,
|
||||||
|
.storage-results-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-results-page {
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-tests-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-speed-option {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 90%, var(--muted));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-speed-option span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-speed-option small {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-custom-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-custom-fields[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-custom-fields label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-row {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-row summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-test-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 0.75rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-test-progress-bar {
|
||||||
|
height: 0.45rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-test-progress-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: color-mix(in srgb, var(--primary) 70%, #86efac);
|
||||||
|
transition: width 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-test-progress small {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-status {
|
||||||
|
justify-self: end;
|
||||||
|
padding: 0.12rem 0.45rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-status.is-done { color: #86efac; }
|
||||||
|
.storage-result-status.is-failed { color: #fca5a5; }
|
||||||
|
.storage-result-status.is-running { color: #fde68a; }
|
||||||
|
|
||||||
|
.storage-result-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 0.75rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-detail span {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-detail strong {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #fca5a5 !important;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.storage-ops-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-row summary,
|
||||||
|
.storage-result-detail,
|
||||||
|
.storage-custom-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-result-status {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/static/css/70-tokens.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/* ── Access tokens ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.token-create-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-create-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid rgba(134, 239, 172, 0.3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(134, 239, 172, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal-title {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.125rem);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal .muted-copy {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-reveal .muted-copy code {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
98
backend/static/css/90-responsive.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
@media (max-width: 720px) {
|
||||||
|
.nav-links {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-view,
|
||||||
|
.download-view {
|
||||||
|
min-height: auto;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-grid,
|
||||||
|
.form-footer,
|
||||||
|
.result-header,
|
||||||
|
.site-footer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-grid,
|
||||||
|
.field-grid,
|
||||||
|
.app-shell,
|
||||||
|
.settings-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-progress-side {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-actions .button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
min-height: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header,
|
||||||
|
.table-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-collection-body {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.storage-card-fields {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 |
@@ -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="M3 22L3 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 22V2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 16H9C7.89543 16 7 15.1046 7 14V10C7 8.89543 7.89543 8 9 8H15C16.1046 8 17 8.89543 17 10V14C17 15.1046 16.1046 16 15 16Z" stroke="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 461 B |
6
backend/static/icons/regular/align-justify.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 10H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 18H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 481 B |
10
backend/static/icons/regular/align-left-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="M16.0041 3.995L15.993 4.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0041 3.995L19.993 4.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0041 7.995L19.993 8.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0041 11.995L19.993 12.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0041 15.995L19.993 16.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0041 19.995L19.993 20.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16.0041 19.995L15.993 20.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.0059 3.995L4.00586 3.995L4.00586 19.995H12.0059L12.0059 3.995Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
6
backend/static/icons/regular/align-left.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 10L17 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 18L17 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 487 B |
10
backend/static/icons/regular/align-right-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="M8.00563 20.005L8.01674 19.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00563 20.005L4.01674 19.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00563 16.005L4.01674 15.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00563 12.005L4.01674 11.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00563 8.005L4.01674 7.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.00563 4.005L4.01674 3.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8.00563 4.005L8.01674 3.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12.0059 20.005H20.0059V4.005H12.0059V20.005Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
6
backend/static/icons/regular/align-right.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="M7 10L21 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 18L21 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 487 B |
10
backend/static/icons/regular/align-top-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 16L4.01 15.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 20L4.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 20L8.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 20L12.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 20L16.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20 20L20.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20 16L20.01 15.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 12V4H20V12H4Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 928 B |
4
backend/static/icons/regular/align-vertical-centers.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="M22 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 19V5C8 3.89543 8.89543 3 10 3H14C15.1046 3 16 3.89543 16 5V19C16 20.1046 15.1046 21 14 21H10C8.89543 21 8 20.1046 8 19Z" stroke="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 372 B |
5
backend/static/icons/regular/align-vertical-spacing.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="M22 3L2 3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M22 21L2 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 15V9C8 7.89543 8.89543 7 10 7H14C15.1046 7 16 7.89543 16 9V15C16 16.1046 15.1046 17 14 17H10C8.89543 17 8 16.1046 8 15Z" stroke="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 463 B |
9
backend/static/icons/regular/angle-tool.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<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 21L3 3L9 3V15L21 15V21H3Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13 19V21" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M9 19V21" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M3 7H5" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M3 11H5" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M3 15H5" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
<path d="M17 19V21" stroke="currentColor" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 626 B |
5
backend/static/icons/regular/antenna-off.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 5C12.5523 5 13 4.55228 13 4C13 3.44772 12.5523 3 12 3C11.4477 3 11 3.44772 11 4C11 4.55228 11.4477 5 12 5Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 23L8.11111 19M17 23L15.8889 19M9.5 14L8.11111 19M9.5 14H13.5M9.5 14L10.2998 11.1208M8.11111 19H15.8889M15.8889 19L14.7045 14.7361M11.4444 7L12 5L13.0466 8.76759" 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: 671 B |
8
backend/static/icons/regular/antenna-signal-tag.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<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="M2 15V9C2 5.68629 4.68629 3 8 3H16C19.3137 3 22 5.68629 22 9V15C22 18.3137 19.3137 21 16 21H8C4.68629 21 2 18.3137 2 15Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M15 9C15 9 16 10.125 16 12C16 13.875 15 15 15 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 12.01L12.01 11.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17 7C17 7 19 8.78571 19 12C19 15.2143 17 17 17 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 9C9 9 8 10.125 8 12C8 13.875 9 15 9 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 7C7 7 5 8.78571 5 12C5 15.2143 7 17 7 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 911 B |
7
backend/static/icons/regular/antenna-signal.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="M17.5 8C17.5 8 19 9.5 19 12C19 14.5 17.5 16 17.5 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.5 5C20.5 5 23 7.5 23 12C23 16.5 20.5 19 20.5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6.5 8C6.5 8 5 9.5 5 12C5 14.5 6.5 16 6.5 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M3.5 5C3.5 5 1 7.5 1 12C1 16.5 3.5 19 3.5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 861 B |
6
backend/static/icons/regular/antenna.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="M12 5C12.5523 5 13 4.55228 13 4C13 3.44772 12.5523 3 12 3C11.4477 3 11 3.44772 11 4C11 4.55228 11.4477 5 12 5Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M16 1C16 1 17.5 2 17.5 4C17.5 6 16 7 16 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 1C8 1 6.5 2 6.5 4C6.5 6 8 7 8 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M7 23L8.11111 19M17 23L15.8889 19M14.5 14L12 5L9.5 14M14.5 14H9.5M14.5 14L15.8889 19M9.5 14L8.11111 19M8.11111 19H15.8889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 776 B |
4
backend/static/icons/regular/app-notification.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="M19 8C20.6569 8 22 6.65685 22 5C22 3.34315 20.6569 2 19 2C17.3431 2 16 3.34315 16 5C16 6.65685 17.3431 8 19 8Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M21 12V15C21 18.3137 18.3137 21 15 21H9C5.68629 21 3 18.3137 3 15V9C3 5.68629 5.68629 3 9 3H12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 490 B |
7
backend/static/icons/regular/app-store.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="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.5 5.5L17.5 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.5 5.5L6.5 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M13.5 14H6.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17.5 14H16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 705 B |
7
backend/static/icons/regular/app-window.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="M2 19V5C2 3.89543 2.89543 3 4 3H20C21.1046 3 22 3.89543 22 5V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19Z" stroke="currentColor"/>
|
||||||
|
<path d="M2 7L22 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M5 5.01L5.01 4.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M8 5.01L8.01 4.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11 5.01L11.01 4.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 676 B |
6
backend/static/icons/regular/apple-half.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="M12.1471 21.2646L12 21.2351L11.8529 21.2646C9.47627 21.7399 7.23257 21.4756 5.59352 20.1643C3.96312 18.86 2.75 16.374 2.75 12C2.75 7.52684 3.75792 5.70955 5.08541 5.04581C5.77977 4.69863 6.67771 4.59759 7.82028 4.72943C8.96149 4.86111 10.2783 5.21669 11.7628 5.71153L12.0235 5.79841L12.2785 5.69638C14.7602 4.70367 16.9909 4.3234 18.5578 5.05463C20.0271 5.7403 21.25 7.59326 21.25 12C21.25 16.374 20.0369 18.86 18.4065 20.1643C16.7674 21.4756 14.5237 21.7399 12.1471 21.2646Z" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M12 5.5C12 3 11 2 9 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 6V21" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M15 12L15 14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 910 B |
4
backend/static/icons/regular/apple-imac-2021-side.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 22L8 22M14 22L8 22M8 22L10 13.5M10 13.5L7 2M10 13.5L11.5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M17 22L18 22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 360 B |