16 Commits

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

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

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

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

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

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

This ensures that clients attempting to authenticate but failing (due to
expired, malformed, or disabled tokens) are explicitly notified of the
auth failure instead of proceeding anonymously. True anonymous requests
without any Authorization header remain supported.
2026-05-31 13:02:58 +03:00
d99f8ee82a feat(auth): support API tokens and bearer token authentication
- Add backend services to create, list, and delete API tokens.
- Implement Bearer token authentication to resolve tokens to users.
- Register HTTP routes for managing user tokens under `/account/tokens`.
- Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
2026-05-31 12:50:13 +03:00
0503fad9af feat(admin): redesign storage backend management UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m31s
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.

Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
3423c141be Update 2026-05-31 04:02:28 +03:00
c3558fd353 feat(storage): add S3 backend support and advanced upload limits
- Introduce S3-compatible storage backend support using minio-go.
- Add configuration options for local storage limits, box limits, and rate limiting.
- Implement storage backend selection (local vs S3) for anonymous and registered users.
- Add an `/admin/storage` management interface.
- Update documentation and environment examples with the new configuration variables.
2026-05-31 02:14:10 +03:00
830d2a885c refactor(ui): remaster settings and navigation layout
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
- Update navigation labels from "My Account" to "Dashboard" and "Login" to "Sign in", updating tests accordingly.
- Redesign settings forms into structured sections with improved spacing and layout.
- Add CSS styles for tabs, small buttons, and responsive settings sections to enhance the user experience.
2026-05-30 18:17:13 +03:00
d77f164900 feat: add upload policies, daily limits, and storage quotas
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
- Add environment variables to configure anonymous uploads, daily upload caps, and default user storage limits.
- Update config loader to parse and validate the new settings.
- Implement backend logic to track daily usage and active storage per user.
- Update README and `.env.example` to document the new settings and admin panels.
2026-05-30 17:23:20 +03:00
9a3cb90b17 feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards.

Changes include:
- Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management.
- Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history.
- Implementing admin user management (`/admin/users`) for generating invite links and managing user states.
- Updating the bbolt database schema to store users, sessions, invites, and collections.
- Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
2026-05-30 15:42:35 +03:00
1751 changed files with 22160 additions and 1544 deletions

View File

@@ -10,6 +10,23 @@ WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_ENABLED=true
WARPBOX_THUMBNAIL_EVERY=1m
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
WARPBOX_USER_DAILY_UPLOAD_MB=8192
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
WARPBOX_USAGE_RETENTION_DAYS=30
WARPBOX_LOCAL_STORAGE_MAX_GB=100
WARPBOX_ANONYMOUS_MAX_DAYS=30
WARPBOX_USER_MAX_DAYS=90
WARPBOX_ANONYMOUS_DAILY_BOXES=100
WARPBOX_USER_DAILY_BOXES=250
WARPBOX_ANONYMOUS_ACTIVE_BOXES=500
WARPBOX_USER_ACTIVE_BOXES=1000
WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s

114
CLAUDE.md Normal file
View 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`.

View File

@@ -16,12 +16,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
FROM alpine:3.22
ARG APP_VERSION=dev
RUN apk add --no-cache ca-certificates ffmpeg wget
ENV WARPBOX_ADDR=:8080 \
WARPBOX_DATA_DIR=/data \
WARPBOX_STATIC_DIR=/app/static \
WARPBOX_TEMPLATE_DIR=/app/templates
WARPBOX_TEMPLATE_DIR=/app/templates \
APP_VERSION=${APP_VERSION}
WORKDIR /app

View File

@@ -13,6 +13,27 @@ The default server listens on `:8080`.
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
Upload policy defaults are also configured in megabytes and can later be changed from
`/admin/settings`:
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
- `WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048`
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
- `WARPBOX_USAGE_RETENTION_DAYS=30`
- `WARPBOX_LOCAL_STORAGE_MAX_GB=100`
- `WARPBOX_ANONYMOUS_MAX_DAYS=30`
- `WARPBOX_USER_MAX_DAYS=90`
- `WARPBOX_ANONYMOUS_DAILY_BOXES=100`
- `WARPBOX_USER_DAILY_BOXES=250`
- `WARPBOX_ANONYMOUS_ACTIVE_BOXES=500`
- `WARPBOX_USER_ACTIVE_BOXES=1000`
- `WARPBOX_SHORT_WINDOW_REQUESTS=60`
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
- `WARPBOX_USER_STORAGE_BACKEND=local`
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.
@@ -20,7 +41,12 @@ Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs ca
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
The basic admin console is available at `/admin`. Set `WARPBOX_ADMIN_TOKEN` and use that value to sign in.
On a fresh data directory, visit `/register` to create the first account. That first user becomes
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
links from `/admin/users`.
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
at `/admin/login` if you need to recover access without a user session.
For one-off Go commands, run them from the backend module:
@@ -97,13 +123,41 @@ curl -F sharex=@./screenshot.png \
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
## Stage 4 Accounts + Personal Boxes
- `/register` bootstraps the first admin account only when no users exist.
- `/login` and `/logout` provide cookie-based web sessions.
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
history, and flat collections. Uploading still happens from the homepage.
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is
stored with owner and optional collection metadata.
- Admin users are exempt from the global max upload size on the homepage upload flow. Future
per-user quotas should apply to this same upload path rather than creating a second uploader.
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
user storage quota, and usage retention.
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
The bbolt database and JSON logs remain local under `./data/db` and `./data/logs`.
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
tokens, thumbnails, and cleanup continue to work as before.
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
support will power public forgot-password and optional email delivery.
## Runtime Data
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}/@thumb@{file_id}.jpg` - generated previews where available.
- `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 when the local backend is selected.
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
for anonymous uploads and user ID for signed-in uploads.
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
## Static Asset Policy

View File

@@ -3,8 +3,32 @@ module warpbox.dev/backend
go 1.26
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
golang.org/x/crypto v0.51.0
golang.org/x/image v0.41.0
)
require golang.org/x/sys v0.29.0 // indirect
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
)

View File

@@ -1,16 +1,82 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
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/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -12,6 +12,7 @@ import (
type Config struct {
AppName string
AppVersion string
Environment string
Addr string
BaseURL string
@@ -28,11 +29,33 @@ type Config struct {
ThumbnailEnabled bool
ThumbnailEvery time.Duration
MaxUploadSize int64
DefaultSettings SettingsDefaults
}
type SettingsDefaults struct {
AnonymousUploadsEnabled bool
AnonymousMaxUploadMB float64
AnonymousDailyUploadMB float64
UserDailyUploadMB float64
DefaultUserStorageMB float64
UsageRetentionDays int
LocalStorageMaxGB float64
AnonymousMaxDays int
UserMaxDays int
AnonymousDailyBoxes int
UserDailyBoxes int
AnonymousActiveBoxes int
UserActiveBoxes int
ShortWindowRequests int
ShortWindowSeconds int
AnonymousStorageBackend string
UserStorageBackend string
}
func Load() (Config, error) {
cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
AppVersion: envString("APP_VERSION", "dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
@@ -49,6 +72,25 @@ func Load() (Config, error) {
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
DefaultSettings: SettingsDefaults{
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
AnonymousDailyUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048),
UserDailyUploadMB: envMegabytesLimitFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30),
LocalStorageMaxGB: envGigabytesFloat("WARPBOX_LOCAL_STORAGE_MAX_GB", 100),
AnonymousMaxDays: envInt("WARPBOX_ANONYMOUS_MAX_DAYS", 30),
UserMaxDays: envInt("WARPBOX_USER_MAX_DAYS", 90),
AnonymousDailyBoxes: envInt("WARPBOX_ANONYMOUS_DAILY_BOXES", 100),
UserDailyBoxes: envInt("WARPBOX_USER_DAILY_BOXES", 250),
AnonymousActiveBoxes: envInt("WARPBOX_ANONYMOUS_ACTIVE_BOXES", 500),
UserActiveBoxes: envInt("WARPBOX_USER_ACTIVE_BOXES", 1000),
ShortWindowRequests: envInt("WARPBOX_SHORT_WINDOW_REQUESTS", 60),
ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60),
AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_STORAGE_BACKEND", "local"),
UserStorageBackend: envString("WARPBOX_USER_STORAGE_BACKEND", "local"),
},
}
if cfg.BaseURL == "" {
@@ -57,6 +99,22 @@ func Load() (Config, error) {
if cfg.MaxUploadSize <= 0 {
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
}
if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) ||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) ||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) ||
cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
cfg.DefaultSettings.UsageRetentionDays <= 0 ||
cfg.DefaultSettings.LocalStorageMaxGB <= 0 ||
cfg.DefaultSettings.AnonymousMaxDays <= 0 ||
cfg.DefaultSettings.UserMaxDays <= 0 ||
cfg.DefaultSettings.AnonymousDailyBoxes <= 0 ||
cfg.DefaultSettings.UserDailyBoxes <= 0 ||
cfg.DefaultSettings.AnonymousActiveBoxes <= 0 ||
cfg.DefaultSettings.UserActiveBoxes <= 0 ||
cfg.DefaultSettings.ShortWindowRequests <= 0 ||
cfg.DefaultSettings.ShortWindowSeconds <= 0 {
return Config{}, fmt.Errorf("upload policy settings must be positive, with -1 allowed for upload MB limits")
}
return cfg, nil
}
@@ -109,6 +167,19 @@ func envBool(key string, fallback bool) bool {
return parsed
}
func envInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func envMegabytes(key string, fallback float64) int64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
@@ -122,7 +193,56 @@ func envMegabytes(key string, fallback float64) int64 {
return parsed
}
func envMegabytesFloat(key string, fallback float64) float64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := parseMegabytesFloat(value)
if err != nil {
return fallback
}
return parsed
}
func envMegabytesLimitFloat(key string, fallback float64) float64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := parseMegabytesLimitFloat(value)
if err != nil {
return fallback
}
return parsed
}
func envGigabytesFloat(key string, fallback float64) float64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "GB")
normalized = strings.TrimSuffix(normalized, "Gb")
normalized = strings.TrimSuffix(normalized, "gb")
normalized = strings.TrimSpace(normalized)
parsed, err := strconv.ParseFloat(normalized, 64)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func parseMegabytes(value string) (int64, error) {
sizeMB, err := parseMegabytesFloat(value)
if err != nil {
return 0, err
}
return megabytesToBytes(sizeMB), nil
}
func parseMegabytesFloat(value string) (float64, error) {
normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "MB")
normalized = strings.TrimSuffix(normalized, "Mb")
@@ -137,7 +257,36 @@ func parseMegabytes(value string) (int64, error) {
return 0, fmt.Errorf("megabyte value must be positive")
}
return megabytesToBytes(sizeMB), nil
return sizeMB, nil
}
func parseMegabytesLimitFloat(value string) (float64, error) {
sizeMB, err := parseMegabytesFloatAllowNegativeOne(value)
if err != nil {
return 0, err
}
if !validUnlimitedMegabyteLimit(sizeMB) {
return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited")
}
return sizeMB, nil
}
func parseMegabytesFloatAllowNegativeOne(value string) (float64, error) {
normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "MB")
normalized = strings.TrimSuffix(normalized, "Mb")
normalized = strings.TrimSuffix(normalized, "mb")
normalized = strings.TrimSpace(normalized)
sizeMB, err := strconv.ParseFloat(normalized, 64)
if err != nil {
return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err)
}
return sizeMB, nil
}
func validUnlimitedMegabyteLimit(value float64) bool {
return value > 0 || value == -1
}
func megabytesToBytes(sizeMB float64) int64 {

View File

@@ -0,0 +1,849 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"warpbox.dev/backend/libs/services"
)
func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(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)
}
_, token, err := app.authService.Login("daniel@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("owned upload status = %d, body = %s", response.Code, response.Body.String())
}
var ownedPayload services.UploadResult
if err := json.Unmarshal(response.Body.Bytes(), &ownedPayload); err != nil {
t.Fatalf("json.Unmarshal owned returned error: %v", err)
}
ownedBox, err := app.uploadService.GetBox(ownedPayload.BoxID)
if err != nil {
t.Fatalf("GetBox owned returned error: %v", err)
}
if ownedBox.OwnerID != user.ID {
t.Fatalf("owned OwnerID = %q, want %q", ownedBox.OwnerID, user.ID)
}
owned := uploadThroughApp(t, app)
anonymous, err := app.uploadService.GetBox(owned.BoxID)
if err != nil {
t.Fatalf("GetBox anonymous returned error: %v", err)
}
if anonymous.OwnerID != "" {
t.Fatalf("anonymous OwnerID = %q, want empty", anonymous.OwnerID)
}
boxes, err := app.uploadService.ListBoxes(0)
if err != nil {
t.Fatalf("ListBoxes returned error: %v", err)
}
foundOwned := false
for _, box := range boxes {
if box.OwnerID == user.ID {
foundOwned = true
}
}
if !foundOwned {
t.Fatalf("logged-in upload did not store owner id %q", user.ID)
}
}
func TestBearerTokenUploadActsAsUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
if err != nil {
t.Fatalf("CreateAPIToken returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("token upload status = %d, body = %s", response.Code, response.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
if box.OwnerID != user.ID {
t.Fatalf("OwnerID = %q, want %q", box.OwnerID, user.ID)
}
// An invalid bearer token is an authentication failure, not an anonymous upload.
badRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "x.txt", "x")
badRequest.Header.Set("Accept", "application/json")
badRequest.Header.Set("Authorization", "Bearer wbx_bogus.secret")
badResponse := httptest.NewRecorder()
app.Upload(badResponse, badRequest)
if badResponse.Code != http.StatusUnauthorized {
t.Fatalf("invalid token upload status = %d, body = %s", badResponse.Code, badResponse.Body.String())
}
}
func TestAnonymousUploadWithoutBearerStillWorks(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
response := httptest.NewRecorder()
app.Upload(response, multipartUploadRequest(t, "/api/v1/upload", "file", "anonymous.txt", "anonymous"))
if response.Code != http.StatusCreated {
t.Fatalf("anonymous upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestDisabledUserBearerTokenCannotUpload(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
tokenResult, err := app.authService.CreateAPIToken(user.ID, "cli")
if err != nil {
t.Fatalf("CreateAPIToken returned error: %v", err)
}
if err := app.authService.DisableUser(user.ID, true); err != nil {
t.Fatalf("DisableUser returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "blocked.txt", "blocked")
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+tokenResult.Plaintext)
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("disabled bearer upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestInviteHandlerCreatesUserAndMarksInviteUsed(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
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("friend@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/invite/"+invite.Token, strings.NewReader("username=friend&password=password123"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("token", invite.Token)
response := httptest.NewRecorder()
app.InvitePost(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("InvitePost status = %d, body = %s", response.Code, response.Body.String())
}
if _, err := app.authService.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
t.Fatalf("invite token remained reusable")
}
}
func TestNonOwnerCannotManageOwnedBox(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
owner, err := app.authService.CreateBootstrapUser("owner", "owner@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("other@example.test", services.UserRoleUser, owner.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
other, err := app.authService.AcceptInvite(invite.Token, "other", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
result := createOwnedBoxThroughApp(t, app, owner.ID)
if err := app.uploadService.RenameOwnedBox(result.BoxID, other.ID, "stolen"); err == nil {
t.Fatalf("RenameOwnedBox allowed non-owner")
}
}
func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login 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")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("admin upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestUnlimitedAnonymousUploadPolicyUsesNegativeOne(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.AnonymousMaxUploadMB = -1
policy.AnonymousDailyUploadMB = -1
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("unlimited anonymous upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAnonymousUploadDisabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousUploadsEnabled = false
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", response.Code, response.Body.String())
}
}
func TestAnonymousUploadLimits(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 1
policy.AnonymousDailyUploadMB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
large := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
large.Header.Set("Accept", "application/json")
large.RemoteAddr = "192.0.2.10:1234"
largeResponse := httptest.NewRecorder()
app.Upload(largeResponse, large)
if largeResponse.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("large status = %d, body = %s", largeResponse.Code, largeResponse.Body.String())
}
daily := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", strings.Repeat("x", 2048))
daily.Header.Set("Accept", "application/json")
daily.RemoteAddr = "192.0.2.10:1234"
dailyResponse := httptest.NewRecorder()
app.Upload(dailyResponse, daily)
if dailyResponse.Code != http.StatusTooManyRequests {
t.Fatalf("daily status = %d, body = %s", dailyResponse.Code, dailyResponse.Body.String())
}
}
func TestSignedInUploadQuotaAndOverride(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
user, 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, user.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
normal, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, token, err := app.authService.Login(normal.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
policy := testPolicy(t, app)
policy.DefaultUserStorageMB = 0.001
policy.UserDailyUploadMB = 8
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("quota status = %d, body = %s", response.Code, response.Body.String())
}
override := 10.0
if err := app.authService.SetUserStorageQuota(normal.ID, &override); err != nil {
t.Fatalf("SetUserStorageQuota returned error: %v", err)
}
request = multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response = httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("override status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestSignedInDailyCap(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)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
policy := testPolicy(t, app)
policy.UserDailyUploadMB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "daily.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("daily status = %d, body = %s", response.Code, response.Body.String())
}
}
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) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
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&csrf_token=test-csrf")
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
settingsResponse := httptest.NewRecorder()
app.AdminSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
}
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
uploadRequest.Header.Set("Accept", "application/json")
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusForbidden {
t.Fatalf("upload status = %d, want 403, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
}
func TestAdminUserQuotaPostChangesEnforcement(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)
}
_, adminToken, err := app.authService.Login(admin.Email, "password123")
if err != nil {
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&csrf_token=test-csrf"))
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
quotaRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
quotaRequest.SetPathValue("userID", user.ID)
quotaResponse := httptest.NewRecorder()
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)
if quotaResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminUpdateUserQuota status = %d, body = %s", quotaResponse.Code, quotaResponse.Body.String())
}
_, userToken, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("user Login returned error: %v", err)
}
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
uploadRequest.Header.Set("Accept", "application/json")
uploadRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: userToken})
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("upload status = %d, want 413, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
}
func TestHomeReflectsUploadPolicySettings(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 123
policy.AnonymousDailyUploadMB = 456
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
app.Home(response, request)
if response.Code != http.StatusOK {
t.Fatalf("Home status = %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
t.Fatalf("home did not reflect policy settings: %s", body)
}
if !strings.Contains(body, "warpbox.dev · test ·") {
t.Fatalf("home footer did not include app version: %s", body)
}
}
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.APIDocs(response, request)
if response.Code != http.StatusOK {
t.Fatalf("APIDocs status = %d", response.Code)
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
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)
}
}
func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/api", nil)
response := httptest.NewRecorder()
app.APIDocs(response, request)
if response.Code != http.StatusOK {
t.Fatalf("APIDocs status = %d", response.Code)
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
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)
}
}
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
request := httptest.NewRequest(http.MethodGet, "/admin/storage/new/sftp", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
response := httptest.NewRecorder()
app.AdminNewStorageProvider(response, request)
if response.Code != http.StatusOK {
t.Fatalf("AdminNewStorageProvider status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "Private key") || !strings.Contains(body, "SSH host key") {
t.Fatalf("sftp page did not render sftp fields: %s", body)
}
for _, unwanted := range []string{"Bucket display name", "WebDAV URL", "Share</span>", "Access key"} {
if strings.Contains(body, unwanted) {
t.Fatalf("sftp page rendered irrelevant field %q: %s", unwanted, body)
}
}
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
Provider: services.StorageProviderSFTP,
Name: "NAS",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
editRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/"+cfg.ID+"/edit", nil)
editRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
editRequest.SetPathValue("backendID", cfg.ID)
editResponse := httptest.NewRecorder()
app.AdminEditStorageForm(editResponse, editRequest)
if editResponse.Code != http.StatusOK {
t.Fatalf("AdminEditStorageForm status = %d, body = %s", editResponse.Code, editResponse.Body.String())
}
editBody := editResponse.Body.String()
if !strings.Contains(editBody, "Immutable provider") || strings.Contains(editBody, "Bucket display name") || strings.Contains(editBody, "WebDAV URL") {
t.Fatalf("edit page did not stay provider-specific: %s", editBody)
}
}
func TestAdminStorageEditRejectsProviderMutation(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
Provider: services.StorageProviderSFTP,
Name: "NAS",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
form := strings.NewReader("provider=s3&name=Changed&endpoint=https://s3.example.test&bucket=bucket&access_key=access&secret_key=secret&use_ssl=on&csrf_token=test-csrf")
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/edit", form)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
request.SetPathValue("backendID", cfg.ID)
response := httptest.NewRecorder()
app.AdminEditStorage(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("AdminEditStorage status = %d, body = %s", response.Code, response.Body.String())
}
stored, err := app.uploadService.Storage().BackendConfig(cfg.ID)
if err != nil {
t.Fatalf("BackendConfig returned error: %v", err)
}
if stored.Provider != services.StorageProviderSFTP || stored.Type != services.StorageBackendSFTP || stored.Name != "NAS" {
t.Fatalf("storage backend mutated despite rejected provider change: %+v", stored)
}
}
func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
unauthorized := httptest.NewRecorder()
app.AdminRunStorageCleanup(unauthorized, httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil))
if unauthorized.Code != http.StatusSeeOther {
t.Fatalf("unauthorized cleanup status = %d", unauthorized.Code)
}
adminToken := createAdminSession(t, app)
missingCSRFRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", nil)
missingCSRFRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
missingCSRFResponse := httptest.NewRecorder()
app.AdminRunStorageCleanup(missingCSRFResponse, missingCSRFRequest)
if missingCSRFResponse.Code != http.StatusForbidden {
t.Fatalf("missing csrf cleanup status = %d", missingCSRFResponse.Code)
}
request := httptest.NewRequest(http.MethodPost, "/admin/storage/jobs/cleanup", strings.NewReader("csrf_token=test-csrf"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
response := httptest.NewRecorder()
app.AdminRunStorageCleanup(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("authorized cleanup status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil {
t.Fatalf("TestBackend returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/admin/storage/local/speed-test", strings.NewReader("mode=custom&custom_file_count=2&custom_file_size_mb=0.001&csrf_token=test-csrf"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
request.SetPathValue("backendID", services.StorageBackendLocal)
response := httptest.NewRecorder()
app.AdminStartStorageSpeedTest(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("AdminStartStorageSpeedTest status = %d, body = %s", response.Code, response.Body.String())
}
tests, err := app.uploadService.Storage().ListSpeedTests(services.StorageBackendLocal, 10)
if err != nil {
t.Fatalf("ListSpeedTests returned error: %v", err)
}
if len(tests) != 1 {
t.Fatalf("speed tests len = %d, want 1", len(tests))
}
if tests[0].Mode != services.StorageSpeedModeCustom || tests[0].CustomFileCount != 2 || tests[0].CustomFileSizeMB != 0.001 {
t.Fatalf("custom speed test options were not stored: %+v", tests[0])
}
location := response.Header().Get("Location")
if !strings.Contains(location, "/admin/storage/local/tests") {
t.Fatalf("speed test redirect location = %q", location)
}
}
func TestAdminStorageTestingPageRendersHistory(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
if _, err := app.uploadService.Storage().TestBackend(services.StorageBackendLocal); err != nil {
t.Fatalf("TestBackend returned error: %v", err)
}
test, err := app.uploadService.Storage().StartSpeedTest(services.StorageBackendLocal, services.StorageSpeedModeSmall)
if err != nil {
t.Fatalf("StartSpeedTest returned error: %v", err)
}
app.uploadService.Storage().RunSpeedTest(context.Background(), test.ID)
request := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.SetPathValue("backendID", services.StorageBackendLocal)
response := httptest.NewRecorder()
app.AdminStorageTests(response, request)
if response.Code != http.StatusOK {
t.Fatalf("AdminStorageTests status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "New Test") || !strings.Contains(body, "Many small files") || strings.Contains(body, "storage-test-menu") {
t.Fatalf("testing page missing expected page-based controls: %s", body)
}
jsonRequest := httptest.NewRequest(http.MethodGet, "/admin/storage/local/tests.json", nil)
jsonRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
jsonRequest.SetPathValue("backendID", services.StorageBackendLocal)
jsonResponse := httptest.NewRecorder()
app.AdminStorageTestsJSON(jsonResponse, jsonRequest)
if jsonResponse.Code != http.StatusOK {
t.Fatalf("AdminStorageTestsJSON status = %d, body = %s", jsonResponse.Code, jsonResponse.Body.String())
}
if !strings.Contains(jsonResponse.Body.String(), `"progress":100`) || !strings.Contains(jsonResponse.Body.String(), `"stage":"complete"`) {
t.Fatalf("tests json missing progress fields: %s", jsonResponse.Body.String())
}
}
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
t.Helper()
user, err := app.authService.UserByID(userID)
if err != nil {
t.Fatalf("UserByID returned error: %v", err)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("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)
}
return payload
}
func createAdminSession(t *testing.T, app *App) string {
t.Helper()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil && !strings.Contains(err.Error(), "registration is closed") {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
return token
}
func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
t.Helper()
policy, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
return policy
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@ type apiDocsData struct {
}
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "api.html", web.PageData{
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
Title: "API documentation",
Description: "Curl and ShareX upload examples for Warpbox.",
Data: apiDocsData{

View File

@@ -14,25 +14,89 @@ type App struct {
logger *slog.Logger
renderer *web.Renderer
uploadService *services.UploadService
authService *services.AuthService
settingsService *services.SettingsService
rateLimiter *rateLimiter
}
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *App {
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
return &App{
cfg: cfg,
logger: logger,
renderer: renderer,
uploadService: uploadService,
authService: authService,
settingsService: settingsService,
rateLimiter: newRateLimiter(),
}
}
func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, page string, data web.PageData) {
if data.CurrentUser == nil {
data.CurrentUser = a.currentPublicUser(r)
}
data.CSRFToken = a.csrfToken(w, r)
a.renderer.Render(w, status, page, data)
}
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs)
mux.HandleFunc("GET /register", a.Register)
mux.HandleFunc("POST /register", a.RegisterPost)
mux.HandleFunc("GET /login", a.Login)
mux.HandleFunc("POST /login", a.LoginPost)
mux.HandleFunc("POST /logout", a.Logout)
mux.HandleFunc("GET /invite/{token}", a.Invite)
mux.HandleFunc("POST /invite/{token}", a.InvitePost)
mux.HandleFunc("GET /app", a.Dashboard)
mux.HandleFunc("POST /app/collections", a.CreateCollection)
mux.HandleFunc("POST /app/boxes/{boxID}/rename", a.RenameUserBox)
mux.HandleFunc("POST /app/boxes/{boxID}/move", a.MoveUserBox)
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
mux.HandleFunc("GET /account/settings", a.AccountSettings)
mux.HandleFunc("POST /account/password", a.ChangePassword)
mux.HandleFunc("POST /account/tokens", a.CreateUserToken)
mux.HandleFunc("POST /account/tokens/{tokenID}/delete", a.DeleteUserToken)
mux.HandleFunc("GET /admin/login", a.AdminLogin)
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
mux.HandleFunc("GET /admin", a.AdminDashboard)
mux.HandleFunc("GET /admin/files", a.AdminFiles)
mux.HandleFunc("GET /admin/users", a.AdminUsers)
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("GET /admin/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/users/{userID}/disable", a.AdminDisableUser)
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
mux.HandleFunc("POST /admin/users/{userID}/edit", a.AdminUpdateUser)
mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy)
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
@@ -44,6 +108,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /health", a.Health)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)

View File

@@ -0,0 +1,319 @@
package handlers
import (
"net/http"
"strings"
"time"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
const userSessionCookieName = "warpbox_session"
type authPageData struct {
Mode string
Token string
Email string
IsReset bool
Error string
ReturnPath string
}
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
available, err := a.authService.BootstrapAvailable()
if err != nil {
http.Error(w, "unable to check registration", http.StatusInternalServerError)
return
}
if !available {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
}
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
return
}
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
return
}
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
if err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
return
}
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
}
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
if _, ok := a.currentUser(r); ok {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
}
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
return
}
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
return
}
next := r.FormValue("next")
if next == "" {
next = "/app"
}
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
if err != nil {
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
return
}
a.setUserSessionCookie(w, r, token)
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
if !a.validateCSRF(w, r) {
return
}
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
_ = a.authService.Logout(cookie.Value)
}
a.clearUserSessionCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
invite, err := a.authService.InviteByToken(r.PathValue("token"))
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return
}
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
}
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
invite, err := a.authService.InviteByToken(token)
if err != nil {
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return
}
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
return
}
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
if err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
return
}
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
}
type apiTokenView struct {
ID string
Name string
CreatedAt string
LastUsedAt string
}
type accountData struct {
ID string
Email string
Role string
Tokens []apiTokenView
NewToken string
Error string
}
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
a.renderAccount(w, r, http.StatusOK, user, accountData{})
}
// CreateUserToken mints a new personal access token and renders the account
// page with the one-time plaintext shown. The secret is never recoverable after
// this response.
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Unable to read form."})
return
}
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
if err != nil {
a.logger.Warn("api token create failed", "source", "user_activity", "severity", "warn", "code", 4420, "user_id", user.ID, "error", err.Error())
a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
return
}
a.logger.Info("api token created", "source", "user_activity", "severity", "user_activity", "code", 2420, "user_id", user.ID, "token_id", result.Token.ID)
a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
}
func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
}
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
}
func (a *App) renderAccount(w http.ResponseWriter, r *http.Request, status int, user services.User, data accountData) {
tokens, err := a.authService.ListAPITokens(user.ID)
if err != nil {
http.Error(w, "unable to load tokens", http.StatusInternalServerError)
return
}
views := make([]apiTokenView, 0, len(tokens))
for _, token := range tokens {
lastUsed := "Never"
if token.LastUsedAt != nil {
lastUsed = token.LastUsedAt.Format("Jan 2, 2006 15:04")
}
views = append(views, apiTokenView{
ID: token.ID,
Name: token.Name,
CreatedAt: token.CreatedAt.Format("Jan 2, 2006"),
LastUsedAt: lastUsed,
})
}
data.ID = user.ID
data.Email = user.Email
data.Role = user.Role
data.Tokens = views
a.renderPage(w, r, status, "account.html", web.PageData{
Title: "Account settings",
Description: "Manage your Warpbox account.",
CurrentUser: a.authService.PublicUser(user),
Data: data,
})
}
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return
}
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return
}
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
}
func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
a.renderPage(w, r, status, "auth.html", web.PageData{
Title: "Account",
Description: "Sign in to Warpbox.",
Data: data,
})
}
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
_, token, err := a.authService.Login(email, password)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.setUserSessionCookie(w, r, token)
http.Redirect(w, r, path, http.StatusSeeOther)
}
func (a *App) currentUser(r *http.Request) (services.User, bool) {
user, ok, _ := a.currentUserWithAuthError(r)
return user, ok
}
func (a *App) currentUserWithAuthError(r *http.Request) (services.User, bool, error) {
// Personal access tokens via Authorization: Bearer act as their owning user.
// A bearer header is never set by browsers cross-site, so this path is not
// subject to CSRF and intentionally bypasses the session cookie.
if header := r.Header.Get("Authorization"); header != "" {
if raw, ok := strings.CutPrefix(header, "Bearer "); ok {
user, err := a.authService.UserForAPIToken(raw)
if err != nil {
return services.User{}, false, err
}
return user, true, nil
}
}
cookie, err := r.Cookie(userSessionCookieName)
if err != nil {
return services.User{}, false, nil
}
user, _, err := a.authService.UserForSession(cookie.Value)
if err != nil {
return services.User{}, false, nil
}
return user, true, nil
}
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
user, ok := a.currentUser(r)
if ok {
return user, true
}
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
return services.User{}, false
}
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: userSessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
}
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: userSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func safeReturnPath(path string) string {
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
return "/app"
}
return path
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"fmt"
"net/http"
"os"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type dashboardData struct {
User services.PublicUser
Collections []collectionView
Boxes []userBoxView
StorageUsed string
MaxUploadSize string
Selected string
LastInviteURL string
}
type collectionView struct {
ID string
Name string
}
type userBoxView struct {
ID string
Title string
CollectionID string
CollectionName string
FileCount int
Size string
CreatedAt string
ExpiresAt string
URL string
}
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
collections, err := a.authService.ListCollections(user.ID)
if err != nil {
http.Error(w, "unable to load collections", http.StatusInternalServerError)
return
}
collectionNames := map[string]string{}
collectionViews := make([]collectionView, 0, len(collections))
for _, collection := range collections {
collectionNames[collection.ID] = collection.Name
collectionViews = append(collectionViews, collectionView{ID: collection.ID, Name: collection.Name})
}
boxes, err := a.uploadService.UserBoxes(user.ID, collectionNames)
if err != nil {
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return
}
storageUsed, err := a.uploadService.UserStorageUsed(user.ID)
if err != nil {
http.Error(w, "unable to load storage usage", http.StatusInternalServerError)
return
}
selected := r.URL.Query().Get("collection")
boxViews := make([]userBoxView, 0, len(boxes))
for _, row := range boxes {
if selected != "" && row.Box.CollectionID != selected {
continue
}
title := row.Box.Title
if title == "" {
title = fmt.Sprintf("%d file upload", len(row.Box.Files))
}
boxViews = append(boxViews, userBoxView{
ID: row.Box.ID,
Title: title,
CollectionID: row.Box.CollectionID,
CollectionName: row.CollectionName,
FileCount: len(row.Box.Files),
Size: row.TotalSizeLabel,
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"),
URL: "/d/" + row.Box.ID,
})
}
a.renderPage(w, r, http.StatusOK, "dashboard.html", web.PageData{
Title: "My files",
Description: "Your Warpbox personal file space.",
CurrentUser: a.authService.PublicUser(user),
Data: dashboardData{
User: a.authService.PublicUser(user),
Collections: collectionViews,
Boxes: boxViews,
StorageUsed: helpers.FormatBytes(storageUsed),
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
Selected: selected,
},
})
}
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
a.handleUserBoxError(w, r, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
collectionID := r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
http.Error(w, "collection not found", http.StatusForbidden)
return
}
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
a.handleUserBoxError(w, r, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok || !a.validateCSRF(w, r) {
return
}
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
a.handleUserBoxError(w, r, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) handleUserBoxError(w http.ResponseWriter, r *http.Request, err error) {
if os.IsPermission(err) {
http.Error(w, "not allowed", http.StatusForbidden)
return
}
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "unable to update box", http.StatusInternalServerError)
}

View File

@@ -1,8 +1,10 @@
package handlers
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -55,7 +57,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.uploadService.CanDownload(box); err != nil {
a.renderer.Render(w, http.StatusForbidden, "download.html", web.PageData{
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
Title: "Download unavailable",
Description: "This Warpbox link is no longer available.",
Data: downloadPageData{
@@ -74,9 +76,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
}
}
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{
Title: "Download files",
Description: "Download files shared through Warpbox.",
expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox"
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
if locked && box.Obfuscate {
title = "Protected Warpbox link"
description = "This shared box is password protected."
}
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
Title: title,
Description: description,
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
Data: downloadPageData{
Box: boxView{ID: box.ID},
Files: files,
@@ -85,11 +96,18 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
Obfuscated: box.Obfuscate,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
ExpiresLabel: expiresLabel,
},
})
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
@@ -107,7 +125,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
}
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
Title: title,
Description: description,
ImageURL: imageURL,
@@ -139,16 +157,32 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
a.servePlaceholderThumbnail(w, r)
return
}
path := a.uploadService.ThumbnailPath(box, file)
if path == "" {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if err != nil {
// 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
}
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) {
@@ -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) {
path := a.uploadService.FilePath(box, file)
source, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer source.Close()
stat, err := source.Stat()
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
if err != nil {
http.NotFound(w, r)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType)
if attachment {
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) {
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) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {

View File

@@ -26,7 +26,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
return
}
a.renderer.Render(w, http.StatusOK, "manage.html", web.PageData{
a.renderPage(w, r, http.StatusOK, "manage.html", web.PageData{
Title: "Manage upload",
Description: "Delete this anonymous Warpbox upload.",
Data: a.managePageData(box, r.PathValue("token")),
@@ -48,7 +48,7 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
}
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "manage_deleted.html", web.PageData{
a.renderPage(w, r, http.StatusOK, "manage_deleted.html", web.PageData{
Title: "Upload deleted",
Description: "This Warpbox upload has been deleted.",
Data: boxView{ID: r.PathValue("boxID")},

View File

@@ -0,0 +1,176 @@
package handlers
import (
"bytes"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"net/http"
"os"
"path/filepath"
"time"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
// Open Graph image dimensions recommended for large summary cards
// (Discord, Twitter/X, Slack, etc.).
const (
ogImageWidth = 1200
ogImageHeight = 630
ogMaxTiles = 4
ogTileGap = 8
)
var ogBackground = color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff}
// BoxOGImage renders the social-preview image for a box: a collage of up to
// four file thumbnails, or a branded placeholder when none are available yet.
func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return
}
if err := a.uploadService.CanDownload(box); err != nil {
a.serveOGImage(w, r, a.ogPlaceholder())
return
}
// Never leak thumbnails of a locked, obfuscated box. (Protected-but-not-
// obfuscated boxes already show their thumbnails on the download page, so
// they may appear here too.)
hideContents := a.uploadService.IsProtected(box) && box.Obfuscate
thumbs := make([]image.Image, 0, ogMaxTiles)
if !hideContents {
for _, file := range box.Files {
if len(thumbs) >= ogMaxTiles {
break
}
if file.Thumbnail == "" && file.ThumbnailObjectKey == "" {
continue
}
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if err != nil {
continue
}
img, _, decodeErr := image.Decode(object.Body)
object.Body.Close()
if decodeErr == nil {
thumbs = append(thumbs, img)
}
}
}
if len(thumbs) == 0 {
a.serveOGImage(w, r, a.ogPlaceholder())
return
}
a.serveOGImage(w, r, renderCollage(thumbs))
}
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
http.Error(w, "could not render preview image", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/jpeg")
// Social scrapers fetch this rarely and cache on their side; a modest cache
// keeps it fresh as thumbnails finish generating.
w.Header().Set("Cache-Control", "public, max-age=3600")
http.ServeContent(w, r, "og-image.jpg", time.Time{}, bytes.NewReader(buf.Bytes()))
}
// ogPlaceholder builds the branded fallback image: the file placeholder icon
// centered on the brand background.
func (a *App) ogPlaceholder() image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
file, err := os.Open(filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
if err != nil {
return canvas
}
defer file.Close()
icon, _, err := image.Decode(file)
if err != nil {
return canvas
}
// Scale the icon to ~40% of the canvas height and centre it.
target := ogImageHeight * 2 / 5
b := icon.Bounds()
scale := float64(target) / float64(b.Dy())
dw := int(float64(b.Dx()) * scale)
dh := target
x0 := (ogImageWidth - dw) / 2
y0 := (ogImageHeight - dh) / 2
xdraw.CatmullRom.Scale(canvas, image.Rect(x0, y0, x0+dw, y0+dh), icon, b, xdraw.Over, nil)
return canvas
}
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
func renderCollage(thumbs []image.Image) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
cols, rows := collageGrid(len(thumbs))
cellW := (ogImageWidth - ogTileGap*(cols+1)) / cols
cellH := (ogImageHeight - ogTileGap*(rows+1)) / rows
i := 0
for ry := 0; ry < rows && i < len(thumbs); ry++ {
for cx := 0; cx < cols && i < len(thumbs); cx++ {
x0 := ogTileGap + cx*(cellW+ogTileGap)
y0 := ogTileGap + ry*(cellH+ogTileGap)
drawCover(canvas, image.Rect(x0, y0, x0+cellW, y0+cellH), thumbs[i])
i++
}
}
return canvas
}
func collageGrid(n int) (cols, rows int) {
switch {
case n <= 1:
return 1, 1
case n == 2:
return 2, 1
case n == 3:
return 3, 1
default:
return 2, 2
}
}
// drawCover scales src to completely fill dst, cropping the overflow (centred),
// preserving aspect ratio — the CSS object-fit: cover equivalent.
func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) {
b := src.Bounds()
iw, ih := b.Dx(), b.Dy()
if iw <= 0 || ih <= 0 {
return
}
cellAR := float64(cell.Dx()) / float64(cell.Dy())
imgAR := float64(iw) / float64(ih)
var sw, sh int
if imgAR > cellAR {
// Source is wider than the cell: crop the sides.
sh = ih
sw = int(float64(ih) * cellAR)
} else {
// Source is taller: crop top/bottom.
sw = iw
sh = int(float64(iw) / cellAR)
}
sx := b.Min.X + (iw-sw)/2
sy := b.Min.Y + (ih-sh)/2
xdraw.CatmullRom.Scale(dst, cell, src, image.Rect(sx, sy, sx+sw, sy+sh), xdraw.Over, nil)
}

View File

@@ -2,20 +2,157 @@ package handlers
import (
"net/http"
"strconv"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type homeData struct {
MaxUploadSize string
LimitSummary string
Collections []collectionView
IsAdmin bool
AnonymousOpen bool
ExpiryOptions []expiryOption
DefaultExpiryMinutes int
}
type expiryOption struct {
Minutes int
Label string
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{
currentUser := a.currentPublicUser(r)
var collections []collectionView
var isAdmin bool
var user services.User
var loggedIn bool
if current, ok := a.currentUser(r); ok {
user = current
loggedIn = true
isAdmin = user.Role == services.UserRoleAdmin
userCollections, err := a.authService.ListCollections(user.ID)
if err == nil {
collections = make([]collectionView, 0, len(userCollections))
for _, collection := range userCollections {
collections = append(collections, collectionView{ID: collection.ID, Name: collection.Name})
}
}
}
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
return
}
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.",
CurrentUser: currentUser,
Data: homeData{
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
MaxUploadSize: maxUploadSize,
LimitSummary: limitSummary,
Collections: collections,
IsAdmin: isAdmin,
AnonymousOpen: settings.AnonymousUploadsEnabled,
ExpiryOptions: expiryOptions,
DefaultExpiryMinutes: defaultExpiry,
},
})
}
// homeExpiryOptions builds the expiry ladder offered on the upload form, capped to
// the viewer's effective maximum retention. Admins have no cap (the dropdown is
// still capped at 365 days for sanity; the API accepts any value for admins).
func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) ([]expiryOption, int) {
maxDays := settings.AnonymousMaxDays
unlimited := false
switch {
case isAdmin:
unlimited = true
case loggedIn:
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
}
return buildExpiryOptions(maxDays, unlimited)
}
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
ladder := []int{60, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
capMinutes := maxDays * 24 * 60
if unlimited || capMinutes <= 0 {
capMinutes = 525600
}
options := make([]expiryOption, 0, len(ladder)+1)
seen := make(map[int]bool)
for _, minutes := range ladder {
if minutes > capMinutes {
break
}
options = append(options, expiryOption{Minutes: minutes, Label: expiryLabel(minutes)})
seen[minutes] = true
}
// Always offer the exact cap as a final choice (e.g. a 15-day limit).
if !unlimited && !seen[capMinutes] {
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
}
if len(options) == 0 {
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
}
// Default to 24h when available, otherwise the smallest option offered.
defaultMinutes := options[0].Minutes
if seen[1440] {
defaultMinutes = 1440
}
return options, defaultMinutes
}
func expiryLabel(minutes int) string {
switch {
case minutes < 60:
return strconv.Itoa(minutes) + " minutes"
case minutes < 1440:
hours := minutes / 60
if hours == 1 {
return "1 hour"
}
return strconv.Itoa(hours) + " hours"
case minutes == 1440:
return "24 hours"
default:
days := minutes / 1440
if days == 1 {
return "1 day"
}
return strconv.Itoa(days) + " days"
}
}
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
if isAdmin {
return "No file size limit", "Admin uploads bypass storage and daily caps."
}
if !loggedIn {
if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files."
}
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
}
policy := a.settingsService.EffectivePolicyForUser(settings, user)
maxUpload := a.uploadService.MaxUploadSizeLabel()
if policy.MaxUploadMB < 0 {
maxUpload = "unlimited"
} else if policy.MaxUploadMB > 0 {
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
}
quota := "unlimited"
if policy.StorageQuotaSet {
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
}
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max."
}

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

View File

@@ -7,8 +7,8 @@ import (
func TestSetStaticCacheHeaders(t *testing.T) {
tests := map[string]string{
"/static/css/app.css": "public, max-age=86400",
"/static/js/app.js": "public, max-age=86400",
"/static/css/00-base.css": "public, max-age=86400",
"/static/js/00-utils.js": "public, max-age=86400",
"/static/img/preview.webp": "public, max-age=31536000, immutable",
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",

View File

@@ -1,12 +1,14 @@
package handlers
import (
"context"
"errors"
"fmt"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
@@ -14,24 +16,99 @@ import (
)
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil {
user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil {
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return
}
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, err := a.settingsService.UploadPolicy()
if err != nil {
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
return
}
if !loggedIn && !settings.AnonymousUploadsEnabled {
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return
}
parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())
if !isAdminUpload && parseLimit > 0 {
r.Body = http.MaxBytesReader(w, r.Body, parseLimit)
}
if isAdminUpload {
parseLimit = 32 << 20
} else if parseLimit <= 0 {
parseLimit = 32 << 20
}
if err := r.ParseMultipartForm(parseLimit); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return
}
files := uploadFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID string
var collectionID string
if loggedIn {
ownerID = user.ID
collectionID = r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
return
}
}
if !isAdminUpload {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
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{
MaxDays: parseInt(r.FormValue("max_days")),
MaxDays: maxDays,
ExpiresInMinutes: expiresMinutes,
MaxDownloads: parseInt(r.FormValue("max_downloads")),
Password: r.FormValue("password"),
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
OwnerID: ownerID,
CollectionID: collectionID,
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
CreatorIP: uploadClientIP(r),
StorageBackendID: effectivePolicy.StorageBackendID,
})
if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
if !isAdminUpload {
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())
}
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4403, "error", err.Error())
}
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
if wantsJSON(r) {
@@ -44,6 +121,138 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
if len(files) == 0 {
return 0, ""
}
now := time.Now().UTC()
if policy.MaxUploadMB > 0 {
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, file := range files {
if file.Size > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
}
}
}
if !loggedIn {
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
}
if 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, ""
}
usage, err := a.settingsService.UsageForUser(user.ID, now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "daily upload limit reached"
}
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil {
return http.StatusInternalServerError, "storage quota could not be checked"
}
if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
return http.StatusRequestEntityTooLarge, "storage quota reached"
}
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message
}
return 0, ""
}
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
now := time.Now().UTC()
if loggedIn {
return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
}
return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
}
func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
if loggedIn {
return a.settingsService.EffectivePolicyForUser(settings, user)
}
return a.settingsService.EffectivePolicyForAnonymous(settings)
}
func (a *App) checkStorageBackendCapacity(backendID string, settings services.UploadPolicySettings, totalBytes int64) (int, string) {
if backendID != services.StorageBackendLocal {
return 0, ""
}
backend, err := a.uploadService.Storage().Backend(services.StorageBackendLocal)
if err != nil {
return http.StatusInternalServerError, "storage backend could not be checked"
}
used, err := backend.Usage(context.Background())
if err != nil {
return http.StatusInternalServerError, "storage backend usage could not be checked"
}
if used+totalBytes > services.GigabytesToBytes(settings.LocalStorageMaxGB) {
return http.StatusRequestEntityTooLarge, "local storage limit reached"
}
return 0, ""
}
func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
if policy.MaxUploadMB < 0 {
return -1
}
if loggedIn && policy.MaxUploadMB <= 0 {
return fallback * 8
}
if policy.MaxUploadMB > 0 {
return services.MegabytesToBytes(policy.MaxUploadMB) * 8
}
return fallback * 8
}
func uploadClientIP(r *http.Request) string {
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 {
var total int64
for _, file := range files {
total += file.Size
}
return total
}
func parseInt(value string) int {
if value == "" {
return 0

View File

@@ -179,22 +179,41 @@ func newTestApp(t *testing.T) (*App, func()) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{
AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024,
DefaultSettings: config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 1,
AnonymousDailyUploadMB: 8,
UserDailyUploadMB: 8,
DefaultUserStorageMB: 16,
UsageRetentionDays: 30,
},
}
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil {
service.Close()
t.Fatalf("NewRenderer returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service), func() {
authService, err := services.NewAuthService(service.DB(), cfg.BaseURL)
if err != nil {
service.Close()
t.Fatalf("NewAuthService returned error: %v", err)
}
settingsService, err := services.NewSettingsService(service.DB(), cfg.DefaultSettings)
if err != nil {
service.Close()
t.Fatalf("NewSettingsService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() {
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
@@ -237,6 +256,29 @@ func multipartUploadRequest(t *testing.T, path, field, filename, body string) *h
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 {
t.Helper()
parts := strings.Split(strings.TrimRight(value, "/"), "/")

View File

@@ -13,7 +13,7 @@ import (
)
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil {
return nil, err
}
@@ -22,8 +22,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
if err != nil {
return nil, err
}
authService, err := services.NewAuthService(uploadService.DB(), cfg.BaseURL)
if err != nil {
uploadService.Close()
return nil, err
}
settingsService, err := services.NewSettingsService(uploadService.DB(), cfg.DefaultSettings)
if err != nil {
uploadService.Close()
return nil, err
}
stopJobs := jobs.StartAll(cfg, logger, uploadService)
app := handlers.NewApp(cfg, logger, renderer, uploadService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService)
router := http.NewServeMux()
app.RegisterRoutes(router)

View File

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

View File

@@ -1,11 +1,14 @@
package jobs
import (
"bytes"
"context"
"image"
_ "image/gif"
"image/jpeg"
_ "image/jpeg"
_ "image/png"
"io"
"log/slog"
"os"
"os/exec"
@@ -17,7 +20,7 @@ import (
"warpbox.dev/backend/libs/services"
)
type thumbnailJobResult struct {
type ThumbnailJobResult struct {
Scanned int
Generated 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) {
boxes, err := uploadService.ListBoxes(0)
if err != nil {
return thumbnailJobResult{}, err
func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
return generateMissingThumbnails(uploadService, logger)
}
var result thumbnailJobResult
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
boxes, err := uploadService.ListBoxes(0)
if err != nil {
return ThumbnailJobResult{}, err
}
var result ThumbnailJobResult
now := time.Now().UTC()
for _, box := range boxes {
if !box.ExpiresAt.After(now) {
@@ -85,8 +92,8 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
return result, nil
}
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (thumbnailJobResult, error) {
var result thumbnailJobResult
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
var result ThumbnailJobResult
if !box.ExpiresAt.After(time.Now().UTC()) {
return result, nil
}
@@ -129,43 +136,71 @@ func needsThumbnail(file services.File) bool {
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
thumbnailName := "@thumb@" + file.ID + ".jpg"
thumbnailPath := uploadService.ThumbnailPath(box, services.File{Thumbnail: thumbnailName})
sourcePath := uploadService.FilePath(box, file)
object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil {
return "", err
}
defer object.Body.Close()
switch {
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/"):
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:
return "", nil
}
}
func createImageThumbnail(sourcePath, targetPath string) error {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer source.Close()
func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source)
if err != nil {
return err
return nil, err
}
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 {
return err
return nil, err
}
defer target.Close()
return jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82})
return target.Bytes(), nil
}
func createVideoThumbnail(sourcePath, targetPath string) error {
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourcePath, "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run()
func createVideoThumbnail(source io.Reader) ([]byte, error) {
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 {

View File

@@ -0,0 +1,881 @@
package services
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/mail"
"os"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
"golang.org/x/crypto/argon2"
)
var (
usersBucket = []byte("users")
userEmailsBucket = []byte("user_emails")
sessionsBucket = []byte("sessions")
invitesBucket = []byte("invites")
collectionsBucket = []byte("collections")
apiTokensBucket = []byte("api_tokens")
)
// apiTokenPrefix marks raw API tokens so clients and logs can recognise them.
const apiTokenPrefix = "wbx_"
var (
ErrTokenInvalid = errors.New("api token is invalid")
ErrTokenNotFound = errors.New("api token not found")
)
const (
UserRoleAdmin = "admin"
UserRoleUser = "user"
UserStatusActive = "active"
UserStatusDisabled = "disabled"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrRegistrationClosed = errors.New("registration is closed")
ErrInviteInvalid = errors.New("invite is invalid")
ErrUserDisabled = errors.New("user is disabled")
)
type AuthService struct {
db *bbolt.DB
baseURL string
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
Status string `json:"status"`
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
Policy UserPolicy `json:"policy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserPolicy struct {
MaxUploadMB *float64 `json:"maxUploadMb,omitempty"`
DailyUploadMB *float64 `json:"dailyUploadMb,omitempty"`
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
MaxDays *int `json:"maxDays,omitempty"`
DailyBoxes *int `json:"dailyBoxes,omitempty"`
ActiveBoxes *int `json:"activeBoxes,omitempty"`
ShortWindowRequests *int `json:"shortWindowRequests,omitempty"`
StorageBackendID *string `json:"storageBackendId,omitempty"`
}
type PublicUser struct {
ID string
Username string
Email string
Role string
Status string
StorageQuotaMB *float64
Policy UserPolicy
CreatedAt time.Time
}
type Session struct {
ID string `json:"id"`
UserID string `json:"userId"`
TokenHash string `json:"tokenHash"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
type Invite struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
Email string `json:"email"`
Role string `json:"role"`
TokenHash string `json:"tokenHash"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
UsedAt *time.Time `json:"usedAt,omitempty"`
UsedByUserID string `json:"usedByUserId,omitempty"`
}
type Collection struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// APIToken is a long-lived personal access token. Only the SHA-256 hash of the
// secret is stored; the plaintext is shown to the user exactly once at creation.
type APIToken struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
TokenHash string `json:"tokenHash"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// APITokenResult carries the one-time plaintext alongside the stored token.
type APITokenResult struct {
Token APIToken
Plaintext string
}
type InviteResult struct {
Invite Invite
URL string
Token string
}
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
err := db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket, apiTokensBucket} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *AuthService) BootstrapAvailable() (bool, error) {
count := 0
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(usersBucket).ForEach(func(_, _ []byte) error {
count++
return nil
})
})
return count == 0, err
}
func (s *AuthService) CreateBootstrapUser(username, email, password string) (User, error) {
available, err := s.BootstrapAvailable()
if err != nil {
return User{}, err
}
if !available {
return User{}, ErrRegistrationClosed
}
return s.createUser(username, email, password, UserRoleAdmin)
}
func (s *AuthService) Login(email, password string) (User, string, error) {
user, err := s.UserByEmail(email)
if err != nil {
return User{}, "", ErrInvalidCredentials
}
if user.Status != UserStatusActive {
return User{}, "", ErrUserDisabled
}
if !VerifyPasswordHash(user.PasswordHash, password) {
return User{}, "", ErrInvalidCredentials
}
token := randomID(32)
session := Session{
ID: randomID(12),
UserID: user.ID,
TokenHash: tokenHash(token),
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
}
err = s.db.Update(func(tx *bbolt.Tx) error {
data, err := json.Marshal(session)
if err != nil {
return err
}
return tx.Bucket(sessionsBucket).Put([]byte(session.ID), data)
})
return user, session.ID + "." + token, err
}
func (s *AuthService) UserForSession(raw string) (User, Session, error) {
sessionID, token, ok := strings.Cut(raw, ".")
if !ok || sessionID == "" || token == "" {
return User{}, Session{}, os.ErrNotExist
}
var session Session
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(sessionsBucket).Get([]byte(sessionID))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &session)
})
if err != nil {
return User{}, Session{}, err
}
if time.Now().UTC().After(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(tokenHash(token)), []byte(session.TokenHash)) != 1 {
return User{}, Session{}, os.ErrPermission
}
user, err := s.UserByID(session.UserID)
if err != nil {
return User{}, Session{}, err
}
if user.Status != UserStatusActive {
return User{}, Session{}, ErrUserDisabled
}
return user, session, nil
}
func (s *AuthService) Logout(raw string) error {
sessionID, _, ok := strings.Cut(raw, ".")
if !ok || sessionID == "" {
return nil
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(sessionsBucket).Delete([]byte(sessionID))
})
}
// CreateAPIToken mints a new personal access token for the user. The returned
// plaintext is the only time the secret is available; only its hash is stored.
func (s *AuthService) CreateAPIToken(userID, name string) (APITokenResult, error) {
if userID == "" {
return APITokenResult{}, fmt.Errorf("user is required")
}
name = strings.TrimSpace(name)
if name == "" {
name = "Untitled token"
}
if len(name) > 80 {
name = name[:80]
}
secret := randomID(32)
token := APIToken{
ID: randomID(12),
UserID: userID,
Name: name,
TokenHash: apiTokenHash(secret),
CreatedAt: time.Now().UTC(),
}
if err := s.saveAPIToken(token); err != nil {
return APITokenResult{}, err
}
plaintext := apiTokenPrefix + token.ID + "." + secret
return APITokenResult{Token: token, Plaintext: plaintext}, nil
}
// ListAPITokens returns the user's tokens, newest first.
func (s *AuthService) ListAPITokens(userID string) ([]APIToken, error) {
tokens := make([]APIToken, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(apiTokensBucket).ForEach(func(_, data []byte) error {
var token APIToken
if err := json.Unmarshal(data, &token); err != nil {
return err
}
if token.UserID == userID {
tokens = append(tokens, token)
}
return nil
})
})
if err != nil {
return nil, err
}
sort.Slice(tokens, func(i, j int) bool {
return tokens[i].CreatedAt.After(tokens[j].CreatedAt)
})
return tokens, nil
}
// DeleteAPIToken removes a token, but only if it belongs to the given user.
func (s *AuthService) DeleteAPIToken(userID, tokenID string) error {
if userID == "" || tokenID == "" {
return ErrTokenNotFound
}
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(apiTokensBucket)
data := bucket.Get([]byte(tokenID))
if data == nil {
return ErrTokenNotFound
}
var token APIToken
if err := json.Unmarshal(data, &token); err != nil {
return err
}
if token.UserID != userID {
return ErrTokenNotFound
}
return bucket.Delete([]byte(tokenID))
})
}
// UserForAPIToken resolves a raw bearer token to its owning user. It records
// last-used time on a best-effort basis. The user must exist and be enabled.
func (s *AuthService) UserForAPIToken(raw string) (User, error) {
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, apiTokenPrefix)
tokenID, secret, ok := strings.Cut(raw, ".")
if !ok || tokenID == "" || secret == "" {
return User{}, ErrTokenInvalid
}
var token APIToken
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(apiTokensBucket).Get([]byte(tokenID))
if data == nil {
return ErrTokenInvalid
}
return json.Unmarshal(data, &token)
})
if err != nil {
return User{}, ErrTokenInvalid
}
if subtle.ConstantTimeCompare([]byte(apiTokenHash(secret)), []byte(token.TokenHash)) != 1 {
return User{}, ErrTokenInvalid
}
user, err := s.UserByID(token.UserID)
if err != nil {
return User{}, ErrTokenInvalid
}
if user.Status != UserStatusActive {
return User{}, ErrUserDisabled
}
now := time.Now().UTC()
token.LastUsedAt = &now
_ = s.saveAPIToken(token)
return user, nil
}
func (s *AuthService) saveAPIToken(token APIToken) error {
return s.db.Update(func(tx *bbolt.Tx) error {
data, err := json.Marshal(token)
if err != nil {
return err
}
return tx.Bucket(apiTokensBucket).Put([]byte(token.ID), data)
})
}
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
email, err := normalizeEmail(email)
if err != nil {
return InviteResult{}, err
}
if role == "" {
role = UserRoleUser
}
if role != UserRoleAdmin && role != UserRoleUser {
role = UserRoleUser
}
if expiresIn <= 0 {
expiresIn = 7 * 24 * time.Hour
}
token := randomID(32)
invite := Invite{
ID: randomID(12),
Email: email,
Role: role,
TokenHash: tokenHash(token),
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(expiresIn),
}
err = s.saveInvite(invite)
if err != nil {
return InviteResult{}, err
}
return InviteResult{
Invite: invite,
Token: token,
URL: fmt.Sprintf("%s/invite/%s", s.baseURL, token),
}, nil
}
func (s *AuthService) AcceptInvite(token, username, password string) (User, error) {
invite, err := s.InviteByToken(token)
if err != nil {
return User{}, err
}
if invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
return User{}, ErrInviteInvalid
}
var user User
if invite.UserID != "" {
user, err = s.UserByID(invite.UserID)
if err != nil {
return User{}, err
}
if err := s.SetPassword(user.ID, password); err != nil {
return User{}, err
}
user, _ = s.UserByID(user.ID)
} else {
user, err = s.createUser(username, invite.Email, password, invite.Role)
if err != nil {
return User{}, err
}
}
now := time.Now().UTC()
invite.UsedAt = &now
invite.UsedByUserID = user.ID
if err := s.saveInvite(invite); err != nil {
return User{}, err
}
return user, nil
}
func (s *AuthService) InviteByToken(token string) (Invite, error) {
hash := tokenHash(token)
var match Invite
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(invitesBucket).ForEach(func(_, value []byte) error {
var invite Invite
if err := json.Unmarshal(value, &invite); err != nil {
return err
}
if subtle.ConstantTimeCompare([]byte(hash), []byte(invite.TokenHash)) == 1 {
match = invite
}
return nil
})
})
if err != nil {
return Invite{}, err
}
if match.ID == "" {
return Invite{}, ErrInviteInvalid
}
return match, nil
}
func (s *AuthService) CreatePasswordResetInvite(userID, createdBy string) (InviteResult, error) {
user, err := s.UserByID(userID)
if err != nil {
return InviteResult{}, err
}
result, err := s.CreateInvite(user.Email, user.Role, createdBy, 24*time.Hour)
if err != nil {
return InviteResult{}, err
}
result.Invite.UserID = user.ID
if err := s.saveInvite(result.Invite); err != nil {
return InviteResult{}, err
}
return result, nil
}
func (s *AuthService) ListUsers() ([]User, error) {
users := make([]User, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(usersBucket).ForEach(func(_, value []byte) error {
var user User
if err := json.Unmarshal(value, &user); err != nil {
return err
}
users = append(users, user)
return nil
})
})
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.After(users[j].CreatedAt)
})
return users, err
}
func (s *AuthService) DisableUser(userID string, disabled bool) error {
user, err := s.UserByID(userID)
if err != nil {
return err
}
if disabled {
user.Status = UserStatusDisabled
} else {
user.Status = UserStatusActive
}
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetPassword(userID, password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.PasswordHash = HashPassword(password)
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error {
if quotaMB != nil && *quotaMB <= 0 {
return fmt.Errorf("storage quota must be positive")
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.StorageQuotaMB = quotaMB
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetUserPolicy(userID string, policy UserPolicy) error {
if err := validateUserPolicy(policy); err != nil {
return err
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.Policy = policy
user.StorageQuotaMB = policy.StorageQuotaMB
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
user, err := s.UserByID(userID)
if err != nil {
return err
}
backendID = strings.TrimSpace(backendID)
if backendID == "" {
user.Policy.StorageBackendID = nil
} else {
user.Policy.StorageBackendID = &backendID
}
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) {
if err := validateUserPolicy(policy); err != nil {
return User{}, err
}
username = strings.TrimSpace(username)
if username == "" {
return User{}, fmt.Errorf("username is required")
}
email, err := normalizeEmail(email)
if err != nil {
return User{}, err
}
if role != UserRoleAdmin && role != UserRoleUser {
return User{}, fmt.Errorf("invalid role")
}
if status != UserStatusActive && status != UserStatusDisabled {
return User{}, fmt.Errorf("invalid status")
}
var updated User
err = s.db.Update(func(tx *bbolt.Tx) error {
users := tx.Bucket(usersBucket)
emails := tx.Bucket(userEmailsBucket)
data := users.Get([]byte(userID))
if data == nil {
return os.ErrNotExist
}
var user User
if err := json.Unmarshal(data, &user); err != nil {
return err
}
if existing := emails.Get([]byte(email)); existing != nil && string(existing) != user.ID {
return fmt.Errorf("email is already registered")
}
if user.Email != email {
if err := emails.Delete([]byte(user.Email)); err != nil {
return err
}
if err := emails.Put([]byte(email), []byte(user.ID)); err != nil {
return err
}
}
user.Username = username
user.Email = email
user.Role = role
user.Status = status
user.Policy = policy
user.StorageQuotaMB = policy.StorageQuotaMB
user.UpdatedAt = time.Now().UTC()
next, err := json.Marshal(user)
if err != nil {
return err
}
if err := users.Put([]byte(user.ID), next); err != nil {
return err
}
updated = user
return nil
})
return updated, err
}
func (s *AuthService) UserByID(id string) (User, error) {
var user User
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(usersBucket).Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &user)
})
return user, err
}
func (s *AuthService) UserByEmail(email string) (User, error) {
email, err := normalizeEmail(email)
if err != nil {
return User{}, err
}
var userID string
err = s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(userEmailsBucket).Get([]byte(email))
if data == nil {
return os.ErrNotExist
}
userID = string(data)
return nil
})
if err != nil {
return User{}, err
}
return s.UserByID(userID)
}
func (s *AuthService) CreateCollection(userID, name string) (Collection, error) {
name = strings.TrimSpace(name)
if name == "" {
return Collection{}, fmt.Errorf("collection name is required")
}
collection := Collection{
ID: randomID(10),
UserID: userID,
Name: name,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
return collection, s.saveCollection(collection)
}
func (s *AuthService) ListCollections(userID string) ([]Collection, error) {
collections := make([]Collection, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(collectionsBucket).ForEach(func(_, value []byte) error {
var collection Collection
if err := json.Unmarshal(value, &collection); err != nil {
return err
}
if collection.UserID == userID {
collections = append(collections, collection)
}
return nil
})
})
sort.Slice(collections, func(i, j int) bool {
return strings.ToLower(collections[i].Name) < strings.ToLower(collections[j].Name)
})
return collections, err
}
func (s *AuthService) CollectionOwnedBy(collectionID, userID string) bool {
if collectionID == "" {
return true
}
collection, err := s.CollectionByID(collectionID)
return err == nil && collection.UserID == userID
}
func (s *AuthService) CollectionByID(id string) (Collection, error) {
var collection Collection
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(collectionsBucket).Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &collection)
})
return collection, err
}
func (s *AuthService) PublicUser(user User) PublicUser {
return PublicUser{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageQuotaMB: user.StorageQuotaMB,
Policy: user.Policy,
CreatedAt: user.CreatedAt,
}
}
func (s *AuthService) createUser(username, email, password, role string) (User, error) {
username = strings.TrimSpace(username)
if username == "" {
return User{}, fmt.Errorf("username is required")
}
email, err := normalizeEmail(email)
if err != nil {
return User{}, err
}
if len(password) < 8 {
return User{}, fmt.Errorf("password must be at least 8 characters")
}
if role != UserRoleAdmin && role != UserRoleUser {
role = UserRoleUser
}
now := time.Now().UTC()
user := User{
ID: randomID(12),
Username: username,
Email: email,
PasswordHash: HashPassword(password),
Role: role,
Status: UserStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
return user, s.db.Update(func(tx *bbolt.Tx) error {
if existing := tx.Bucket(userEmailsBucket).Get([]byte(email)); existing != nil {
return fmt.Errorf("email is already registered")
}
data, err := json.Marshal(user)
if err != nil {
return err
}
if err := tx.Bucket(usersBucket).Put([]byte(user.ID), data); err != nil {
return err
}
return tx.Bucket(userEmailsBucket).Put([]byte(email), []byte(user.ID))
})
}
func (s *AuthService) saveUser(user User) error {
data, err := json.Marshal(user)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(usersBucket).Put([]byte(user.ID), data)
})
}
func (s *AuthService) saveInvite(invite Invite) error {
data, err := json.Marshal(invite)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(invitesBucket).Put([]byte(invite.ID), data)
})
}
func (s *AuthService) saveCollection(collection Collection) error {
data, err := json.Marshal(collection)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(collectionsBucket).Put([]byte(collection.ID), data)
})
}
func normalizeEmail(email string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "", fmt.Errorf("email is required")
}
if _, err := mail.ParseAddress(email); err != nil {
return "", fmt.Errorf("email is invalid")
}
return email, nil
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte("warpbox-session:" + token))
return hex.EncodeToString(sum[:])
}
func apiTokenHash(secret string) string {
sum := sha256.Sum256([]byte("warpbox-api-token:" + secret))
return hex.EncodeToString(sum[:])
}
func HashPassword(password string) string {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
salt = []byte(randomID(16))[:16]
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return "argon2id$v=19$m=65536,t=1,p=4$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash)
}
func VerifyPasswordHash(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}
func validateUserPolicy(policy UserPolicy) error {
if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 && *policy.MaxUploadMB != -1 {
return fmt.Errorf("max upload override must be positive or -1 for unlimited")
}
if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) {
return fmt.Errorf("daily upload override must be positive or -1 for unlimited")
}
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 {
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
}

View File

@@ -0,0 +1,245 @@
package services
import (
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPasswordHashVerification(t *testing.T) {
hash := HashPassword("correct-horse")
if !VerifyPasswordHash(hash, "correct-horse") {
t.Fatalf("VerifyPasswordHash rejected the correct password")
}
if VerifyPasswordHash(hash, "wrong-password") {
t.Fatalf("VerifyPasswordHash accepted the wrong password")
}
}
func TestBootstrapCreatesAdminAndClosesRegistration(t *testing.T) {
auth := newTestAuthService(t)
available, err := auth.BootstrapAvailable()
if err != nil {
t.Fatalf("BootstrapAvailable returned error: %v", err)
}
if !available {
t.Fatalf("BootstrapAvailable = false, want true")
}
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
if user.Role != UserRoleAdmin {
t.Fatalf("role = %q, want admin", user.Role)
}
if _, err := auth.CreateBootstrapUser("other", "other@example.test", "password123"); err == nil {
t.Fatalf("second bootstrap unexpectedly succeeded")
}
}
func TestLoginSessionAndDisabledUser(t *testing.T) {
auth := newTestAuthService(t)
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
if _, _, err := auth.Login("daniel@example.test", "wrong"); err == nil {
t.Fatalf("Login accepted wrong password")
}
_, token, err := auth.Login("daniel@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
sessionUser, _, err := auth.UserForSession(token)
if err != nil {
t.Fatalf("UserForSession returned error: %v", err)
}
if sessionUser.ID != user.ID {
t.Fatalf("session user = %q, want %q", sessionUser.ID, user.ID)
}
if err := auth.DisableUser(user.ID, true); err != nil {
t.Fatalf("DisableUser returned error: %v", err)
}
if _, _, err := auth.UserForSession(token); err == nil {
t.Fatalf("disabled user session still resolved")
}
}
func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
auth := newTestAuthService(t)
admin, err := auth.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := auth.CreateInvite("friend@example.test", UserRoleUser, admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := auth.AcceptInvite(invite.Token, "friend", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
if user.Email != "friend@example.test" {
t.Fatalf("email = %q, want friend@example.test", user.Email)
}
if _, err := auth.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
t.Fatalf("AcceptInvite allowed token reuse")
}
reset, err := auth.CreatePasswordResetInvite(user.ID, admin.ID)
if err != nil {
t.Fatalf("CreatePasswordResetInvite returned error: %v", err)
}
if _, err := auth.AcceptInvite(reset.Token, "", "newpassword123"); err != nil {
t.Fatalf("AcceptInvite reset returned error: %v", err)
}
if _, _, err := auth.Login("friend@example.test", "newpassword123"); err != nil {
t.Fatalf("Login with reset password returned error: %v", err)
}
}
func TestAPITokenLifecycle(t *testing.T) {
auth := newTestAuthService(t)
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
result, err := auth.CreateAPIToken(user.ID, "CLI laptop")
if err != nil {
t.Fatalf("CreateAPIToken returned error: %v", err)
}
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
}
// The secret must never be stored in plaintext — only its hash.
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
t.Fatalf("stored token hash leaks the plaintext secret")
}
resolved, err := auth.UserForAPIToken(result.Plaintext)
if err != nil {
t.Fatalf("UserForAPIToken returned error: %v", err)
}
if resolved.ID != user.ID {
t.Fatalf("resolved user = %q, want %q", resolved.ID, user.ID)
}
tokens, err := auth.ListAPITokens(user.ID)
if err != nil {
t.Fatalf("ListAPITokens returned error: %v", err)
}
if len(tokens) != 1 {
t.Fatalf("token count = %d, want 1", len(tokens))
}
if tokens[0].Name != "CLI laptop" {
t.Fatalf("token name = %q, want %q", tokens[0].Name, "CLI laptop")
}
if tokens[0].LastUsedAt == nil {
t.Fatalf("LastUsedAt not recorded after UserForAPIToken")
}
if _, err := auth.UserForAPIToken(result.Plaintext + "tampered"); err == nil {
t.Fatalf("UserForAPIToken accepted a tampered token")
}
if _, err := auth.UserForAPIToken("wbx_deadbeef.nope"); err == nil {
t.Fatalf("UserForAPIToken accepted an unknown token")
}
if err := auth.DeleteAPIToken(user.ID, tokens[0].ID); err != nil {
t.Fatalf("DeleteAPIToken returned error: %v", err)
}
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
t.Fatalf("deleted token still resolved")
}
remaining, err := auth.ListAPITokens(user.ID)
if err != nil {
t.Fatalf("ListAPITokens returned error: %v", err)
}
if len(remaining) != 0 {
t.Fatalf("token count after delete = %d, want 0", len(remaining))
}
}
func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) {
auth := newTestAuthService(t)
owner, err := auth.CreateBootstrapUser("owner", "owner@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := auth.CreateInvite("other@example.test", UserRoleUser, owner.ID, time.Hour)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
other, err := auth.AcceptInvite(invite.Token, "other", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
result, err := auth.CreateAPIToken(owner.ID, "owner token")
if err != nil {
t.Fatalf("CreateAPIToken returned error: %v", err)
}
tokens, err := auth.ListAPITokens(owner.ID)
if err != nil {
t.Fatalf("ListAPITokens returned error: %v", err)
}
// Another user cannot delete tokens they do not own.
if err := auth.DeleteAPIToken(other.ID, tokens[0].ID); err == nil {
t.Fatalf("DeleteAPIToken allowed deletion across users")
}
// A disabled owner cannot authenticate with their token.
if err := auth.DisableUser(owner.ID, true); err != nil {
t.Fatalf("DisableUser returned error: %v", err)
}
if _, err := auth.UserForAPIToken(result.Plaintext); err == nil {
t.Fatalf("disabled user token still resolved")
}
}
func TestUserPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) {
auth := newTestAuthService(t)
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
unlimited := -1.0
if err := auth.SetUserPolicy(user.ID, UserPolicy{MaxUploadMB: &unlimited, DailyUploadMB: &unlimited}); err != nil {
t.Fatalf("SetUserPolicy rejected -1 unlimited upload limits: %v", err)
}
updated, err := auth.UserByID(user.ID)
if err != nil {
t.Fatalf("UserByID returned error: %v", err)
}
if updated.Policy.MaxUploadMB == nil || *updated.Policy.MaxUploadMB != -1 || updated.Policy.DailyUploadMB == nil || *updated.Policy.DailyUploadMB != -1 {
t.Fatalf("unlimited policy was not persisted: %+v", updated.Policy)
}
}
func newTestAuthService(t *testing.T) *AuthService {
t.Helper()
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := upload.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
auth, err := NewAuthService(upload.DB(), "http://example.test")
if err != nil {
t.Fatalf("NewAuthService returned error: %v", err)
}
return auth
}

View File

@@ -0,0 +1,502 @@
package services
import (
"encoding/json"
"fmt"
"math"
"net"
"strconv"
"strings"
"time"
"go.etcd.io/bbolt"
"warpbox.dev/backend/libs/config"
)
var (
settingsBucket = []byte("settings")
usageBucket = []byte("usage")
)
var settingsKey = []byte("upload_policy")
type UploadPolicySettings struct {
AnonymousUploadsEnabled bool `json:"anonymousUploadsEnabled"`
AnonymousMaxUploadMB float64 `json:"anonymousMaxUploadMb"`
AnonymousDailyUploadMB float64 `json:"anonymousDailyUploadMb"`
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
UsageRetentionDays int `json:"usageRetentionDays"`
LocalStorageMaxGB float64 `json:"localStorageMaxGb"`
AnonymousMaxDays int `json:"anonymousMaxDays"`
UserMaxDays int `json:"userMaxDays"`
AnonymousDailyBoxes int `json:"anonymousDailyBoxes"`
UserDailyBoxes int `json:"userDailyBoxes"`
AnonymousActiveBoxes int `json:"anonymousActiveBoxes"`
UserActiveBoxes int `json:"userActiveBoxes"`
ShortWindowRequests int `json:"shortWindowRequests"`
ShortWindowSeconds int `json:"shortWindowSeconds"`
AnonymousStorageBackend string `json:"anonymousStorageBackend"`
UserStorageBackend string `json:"userStorageBackend"`
}
type UsageRecord struct {
Key string `json:"key"`
SubjectType string `json:"subjectType"`
Subject string `json:"subject"`
Date string `json:"date"`
UploadedBytes int64 `json:"uploadedBytes"`
UploadedBoxes int `json:"uploadedBoxes"`
RequestCount int `json:"requestCount"`
UpdatedAt time.Time `json:"updatedAt"`
}
type EffectiveUploadPolicy struct {
MaxUploadMB float64
DailyUploadMB float64
StorageQuotaMB float64
MaxDays int
DailyBoxes int
ActiveBoxes int
ShortRequests int
ShortWindow time.Duration
StorageBackendID string
StorageQuotaSet bool
}
type SettingsService struct {
db *bbolt.DB
defaults UploadPolicySettings
}
func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*SettingsService, error) {
service := &SettingsService{
db: db,
defaults: UploadPolicySettings{
AnonymousUploadsEnabled: defaults.AnonymousUploadsEnabled,
AnonymousMaxUploadMB: defaults.AnonymousMaxUploadMB,
AnonymousDailyUploadMB: defaults.AnonymousDailyUploadMB,
UserDailyUploadMB: defaults.UserDailyUploadMB,
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
UsageRetentionDays: defaults.UsageRetentionDays,
LocalStorageMaxGB: defaults.LocalStorageMaxGB,
AnonymousMaxDays: defaults.AnonymousMaxDays,
UserMaxDays: defaults.UserMaxDays,
AnonymousDailyBoxes: defaults.AnonymousDailyBoxes,
UserDailyBoxes: defaults.UserDailyBoxes,
AnonymousActiveBoxes: defaults.AnonymousActiveBoxes,
UserActiveBoxes: defaults.UserActiveBoxes,
ShortWindowRequests: defaults.ShortWindowRequests,
ShortWindowSeconds: defaults.ShortWindowSeconds,
AnonymousStorageBackend: defaults.AnonymousStorageBackend,
UserStorageBackend: defaults.UserStorageBackend,
},
}
service.defaults = service.withBuiltinDefaultGaps(service.defaults)
if err := service.validate(service.defaults); err != nil {
return nil, err
}
err := db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{settingsBucket, usageBucket} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *SettingsService) withBuiltinDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
if settings.LocalStorageMaxGB <= 0 {
settings.LocalStorageMaxGB = 100
}
if settings.AnonymousMaxDays <= 0 {
settings.AnonymousMaxDays = 30
}
if settings.UserMaxDays <= 0 {
settings.UserMaxDays = 90
}
if settings.AnonymousDailyBoxes <= 0 {
settings.AnonymousDailyBoxes = 100
}
if settings.UserDailyBoxes <= 0 {
settings.UserDailyBoxes = 250
}
if settings.AnonymousActiveBoxes <= 0 {
settings.AnonymousActiveBoxes = 500
}
if settings.UserActiveBoxes <= 0 {
settings.UserActiveBoxes = 1000
}
if settings.ShortWindowRequests <= 0 {
settings.ShortWindowRequests = 60
}
if settings.ShortWindowSeconds <= 0 {
settings.ShortWindowSeconds = 60
}
if strings.TrimSpace(settings.AnonymousStorageBackend) == "" {
settings.AnonymousStorageBackend = StorageBackendLocal
}
if strings.TrimSpace(settings.UserStorageBackend) == "" {
settings.UserStorageBackend = StorageBackendLocal
}
return settings
}
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
settings := s.defaults
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(settingsBucket).Get(settingsKey)
if data == nil {
return nil
}
if err := json.Unmarshal(data, &settings); err != nil {
return err
}
settings = s.withDefaultGaps(settings)
return nil
})
if err != nil {
return UploadPolicySettings{}, err
}
if err := s.validate(settings); err != nil {
return UploadPolicySettings{}, err
}
return settings, nil
}
func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
if settings.AnonymousMaxUploadMB == 0 {
settings.AnonymousMaxUploadMB = s.defaults.AnonymousMaxUploadMB
}
if settings.AnonymousDailyUploadMB == 0 {
settings.AnonymousDailyUploadMB = s.defaults.AnonymousDailyUploadMB
}
if settings.UserDailyUploadMB == 0 {
settings.UserDailyUploadMB = s.defaults.UserDailyUploadMB
}
if settings.DefaultUserStorageMB <= 0 {
settings.DefaultUserStorageMB = s.defaults.DefaultUserStorageMB
}
if settings.UsageRetentionDays <= 0 {
settings.UsageRetentionDays = s.defaults.UsageRetentionDays
}
if settings.LocalStorageMaxGB <= 0 {
settings.LocalStorageMaxGB = s.defaults.LocalStorageMaxGB
}
if settings.AnonymousMaxDays <= 0 {
settings.AnonymousMaxDays = s.defaults.AnonymousMaxDays
}
if settings.UserMaxDays <= 0 {
settings.UserMaxDays = s.defaults.UserMaxDays
}
if settings.AnonymousDailyBoxes <= 0 {
settings.AnonymousDailyBoxes = s.defaults.AnonymousDailyBoxes
}
if settings.UserDailyBoxes <= 0 {
settings.UserDailyBoxes = s.defaults.UserDailyBoxes
}
if settings.AnonymousActiveBoxes <= 0 {
settings.AnonymousActiveBoxes = s.defaults.AnonymousActiveBoxes
}
if settings.UserActiveBoxes <= 0 {
settings.UserActiveBoxes = s.defaults.UserActiveBoxes
}
if settings.ShortWindowRequests <= 0 {
settings.ShortWindowRequests = s.defaults.ShortWindowRequests
}
if settings.ShortWindowSeconds <= 0 {
settings.ShortWindowSeconds = s.defaults.ShortWindowSeconds
}
if strings.TrimSpace(settings.AnonymousStorageBackend) == "" {
settings.AnonymousStorageBackend = s.defaults.AnonymousStorageBackend
}
if strings.TrimSpace(settings.UserStorageBackend) == "" {
settings.UserStorageBackend = s.defaults.UserStorageBackend
}
return settings
}
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
if err := s.validate(settings); err != nil {
return err
}
data, err := json.Marshal(settings)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(settingsBucket).Put(settingsKey, data)
})
}
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
key := usageKey(subjectType, subject, now)
var record UsageRecord
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(usageBucket).Get([]byte(key))
if data == nil {
record = UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
return nil
}
return json.Unmarshal(data, &record)
})
return record, err
}
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
return s.AddUploadUsage(subjectType, subject, bytes, 0, now)
}
func (s *SettingsService) AddUploadUsage(subjectType, subject string, bytes int64, boxes int, now time.Time) error {
if bytes <= 0 {
bytes = 0
}
if boxes < 0 {
boxes = 0
}
if bytes == 0 && boxes == 0 {
return nil
}
key := usageKey(subjectType, subject, now)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(usageBucket)
record := UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
data := bucket.Get([]byte(key))
if data != nil {
if err := json.Unmarshal(data, &record); err != nil {
return err
}
}
record.UploadedBytes += bytes
record.UploadedBoxes += boxes
record.UpdatedAt = now.UTC()
next, err := json.Marshal(record)
if err != nil {
return err
}
return bucket.Put([]byte(key), next)
})
}
func (s *SettingsService) EffectivePolicyForAnonymous(settings UploadPolicySettings) EffectiveUploadPolicy {
return EffectiveUploadPolicy{
MaxUploadMB: settings.AnonymousMaxUploadMB,
DailyUploadMB: settings.AnonymousDailyUploadMB,
MaxDays: settings.AnonymousMaxDays,
DailyBoxes: settings.AnonymousDailyBoxes,
ActiveBoxes: settings.AnonymousActiveBoxes,
ShortRequests: settings.ShortWindowRequests,
ShortWindow: time.Duration(settings.ShortWindowSeconds) * time.Second,
StorageBackendID: normalizeBackendID(settings.AnonymousStorageBackend),
}
}
func (s *SettingsService) EffectivePolicyForUser(settings UploadPolicySettings, user User) EffectiveUploadPolicy {
policy := EffectiveUploadPolicy{
MaxUploadMB: 0,
DailyUploadMB: settings.UserDailyUploadMB,
StorageQuotaMB: settings.DefaultUserStorageMB,
MaxDays: settings.UserMaxDays,
DailyBoxes: settings.UserDailyBoxes,
ActiveBoxes: settings.UserActiveBoxes,
ShortRequests: settings.ShortWindowRequests,
ShortWindow: time.Duration(settings.ShortWindowSeconds) * time.Second,
StorageBackendID: normalizeBackendID(settings.UserStorageBackend),
StorageQuotaSet: true,
}
if user.StorageQuotaMB != nil {
policy.StorageQuotaMB = *user.StorageQuotaMB
}
if user.Policy.MaxUploadMB != nil {
policy.MaxUploadMB = *user.Policy.MaxUploadMB
}
if user.Policy.DailyUploadMB != nil {
policy.DailyUploadMB = *user.Policy.DailyUploadMB
}
if user.Policy.StorageQuotaMB != nil {
policy.StorageQuotaMB = *user.Policy.StorageQuotaMB
policy.StorageQuotaSet = *user.Policy.StorageQuotaMB > 0
}
if user.Policy.MaxDays != nil {
policy.MaxDays = *user.Policy.MaxDays
}
if user.Policy.DailyBoxes != nil {
policy.DailyBoxes = *user.Policy.DailyBoxes
}
if user.Policy.ActiveBoxes != nil {
policy.ActiveBoxes = *user.Policy.ActiveBoxes
}
if user.Policy.ShortWindowRequests != nil {
policy.ShortRequests = *user.Policy.ShortWindowRequests
}
if user.Policy.StorageBackendID != nil {
policy.StorageBackendID = normalizeBackendID(*user.Policy.StorageBackendID)
}
return policy
}
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
if retentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive")
}
cutoff := now.UTC().AddDate(0, 0, -retentionDays)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(usageBucket)
return bucket.ForEach(func(key, value []byte) error {
var record UsageRecord
if err := json.Unmarshal(value, &record); err != nil {
return err
}
date, err := time.Parse("2006-01-02", record.Date)
if err != nil || date.Before(cutoff) {
return bucket.Delete(key)
}
return nil
})
})
}
func (s *SettingsService) UsageForUser(userID string, now time.Time) (UsageRecord, error) {
return s.Usage("user", userID, now)
}
func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, error) {
return s.Usage("ip", ip, now)
}
func (s *SettingsService) validate(settings UploadPolicySettings) error {
if settings.AnonymousMaxUploadMB < 0 && settings.AnonymousMaxUploadMB != -1 || settings.AnonymousMaxUploadMB == 0 {
return fmt.Errorf("anonymous max upload must be positive or -1 for unlimited")
}
if settings.AnonymousDailyUploadMB < 0 && settings.AnonymousDailyUploadMB != -1 || settings.AnonymousDailyUploadMB == 0 {
return fmt.Errorf("anonymous daily upload must be positive or -1 for unlimited")
}
if settings.UserDailyUploadMB < 0 && settings.UserDailyUploadMB != -1 || settings.UserDailyUploadMB == 0 {
return fmt.Errorf("user daily upload must be positive or -1 for unlimited")
}
if settings.DefaultUserStorageMB <= 0 {
return fmt.Errorf("default user storage must be positive")
}
if settings.UsageRetentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive")
}
if settings.LocalStorageMaxGB <= 0 {
return fmt.Errorf("local storage max must be positive")
}
if settings.AnonymousMaxDays <= 0 || settings.UserMaxDays <= 0 {
return fmt.Errorf("expiration limits must be positive")
}
if settings.AnonymousDailyBoxes <= 0 || settings.UserDailyBoxes <= 0 {
return fmt.Errorf("daily box limits must be positive")
}
if settings.AnonymousActiveBoxes <= 0 || settings.UserActiveBoxes <= 0 {
return fmt.Errorf("active box limits must be positive")
}
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
return fmt.Errorf("short-window rate limits must be positive")
}
return nil
}
func ParseMegabytesValue(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("megabyte value is required")
}
value = strings.TrimSuffix(value, "MB")
value = strings.TrimSuffix(value, "Mb")
value = strings.TrimSuffix(value, "mb")
value = strings.TrimSpace(value)
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
if parsed <= 0 {
return 0, fmt.Errorf("megabyte value must be positive")
}
return parsed, nil
}
func ParseMegabytesLimitValue(value string) (float64, error) {
parsed, err := parseMegabytesNumber(value)
if err != nil {
return 0, err
}
if parsed == -1 {
return -1, nil
}
if parsed <= 0 {
return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited")
}
return parsed, nil
}
func parseMegabytesNumber(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, fmt.Errorf("megabyte value is required")
}
value = strings.TrimSuffix(value, "MB")
value = strings.TrimSuffix(value, "Mb")
value = strings.TrimSuffix(value, "mb")
value = strings.TrimSpace(value)
return strconv.ParseFloat(value, 64)
}
func MegabytesToBytes(value float64) int64 {
return int64(value * 1024 * 1024)
}
func GigabytesToBytes(value float64) int64 {
return int64(value * 1024 * 1024 * 1024)
}
func FormatMegabytesFromBytes(value int64) string {
mb := float64(value) / 1024 / 1024
mb = math.Round(mb*100) / 100
return FormatMegabytesLabel(mb)
}
func FormatMegabytesLabel(value float64) string {
if value < 0 {
return "unlimited"
}
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
}
func usageKey(subjectType, subject string, now time.Time) string {
return subjectType + ":" + subject + ":" + usageDate(now)
}
func usageDate(now time.Time) string {
return now.UTC().Format("2006-01-02")
}
func normalizeBackendID(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return StorageBackendLocal
}
return id
}
func ClientIP(remoteAddr, forwardedFor string) string {
if forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
host := remoteAddr
if strings.Contains(remoteAddr, ":") {
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = splitHost
}
}
return host
}

View File

@@ -0,0 +1,235 @@
package services
import (
"log/slog"
"path/filepath"
"testing"
"time"
"warpbox.dev/backend/libs/config"
)
func TestSettingsLoadDefaultsAndOverrides(t *testing.T) {
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if !policy.AnonymousUploadsEnabled || policy.AnonymousMaxUploadMB != 512 {
t.Fatalf("default policy = %+v", policy)
}
policy.AnonymousUploadsEnabled = false
policy.UserDailyUploadMB = 123
if err := settings.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
next, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if next.AnonymousUploadsEnabled || next.UserDailyUploadMB != 123 {
t.Fatalf("override policy = %+v", next)
}
}
func TestSettingsUseNewEnvDefaultsUntilSaved(t *testing.T) {
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
defer upload.Close()
first, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 111,
AnonymousDailyUploadMB: 222,
UserDailyUploadMB: 333,
DefaultUserStorageMB: 444,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService first returned error: %v", err)
}
firstPolicy, err := first.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy first returned error: %v", err)
}
if firstPolicy.AnonymousMaxUploadMB != 111 {
t.Fatalf("first AnonymousMaxUploadMB = %v, want 111", firstPolicy.AnonymousMaxUploadMB)
}
second, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 555,
AnonymousDailyUploadMB: 666,
UserDailyUploadMB: 777,
DefaultUserStorageMB: 888,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService second returned error: %v", err)
}
secondPolicy, err := second.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy second returned error: %v", err)
}
if secondPolicy.AnonymousMaxUploadMB != 555 {
t.Fatalf("second AnonymousMaxUploadMB = %v, want 555", secondPolicy.AnonymousMaxUploadMB)
}
if err := second.UpdateUploadPolicy(secondPolicy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
third, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 999,
AnonymousDailyUploadMB: 999,
UserDailyUploadMB: 999,
DefaultUserStorageMB: 999,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService third returned error: %v", err)
}
thirdPolicy, err := third.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy third returned error: %v", err)
}
if thirdPolicy.AnonymousMaxUploadMB != 555 {
t.Fatalf("third AnonymousMaxUploadMB = %v, want persisted 555", thirdPolicy.AnonymousMaxUploadMB)
}
}
func TestSettingsRejectInvalidMegabytes(t *testing.T) {
if _, err := ParseMegabytesValue("0"); err == nil {
t.Fatalf("ParseMegabytesValue accepted zero")
}
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.DefaultUserStorageMB = -1
if err := settings.UpdateUploadPolicy(policy); err == nil {
t.Fatalf("UpdateUploadPolicy accepted negative storage")
}
}
func TestUploadPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) {
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.AnonymousMaxUploadMB = -1
policy.AnonymousDailyUploadMB = -1
policy.UserDailyUploadMB = -1
if err := settings.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy rejected -1 unlimited upload limits: %v", err)
}
next, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if next.AnonymousMaxUploadMB != -1 || next.AnonymousDailyUploadMB != -1 || next.UserDailyUploadMB != -1 {
t.Fatalf("unlimited upload limits were not persisted: %+v", next)
}
if got := FormatMegabytesLabel(-1); got != "unlimited" {
t.Fatalf("FormatMegabytesLabel(-1) = %q, want unlimited", got)
}
}
func TestDailyUsageAndCleanup(t *testing.T) {
settings := newTestSettingsService(t)
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
if err := settings.AddUsage("ip", "127.0.0.1", 1024, now); err != nil {
t.Fatalf("AddUsage returned error: %v", err)
}
if err := settings.AddUsage("ip", "127.0.0.1", 2048, now); err != nil {
t.Fatalf("AddUsage returned error: %v", err)
}
usage, err := settings.UsageForIP("127.0.0.1", now)
if err != nil {
t.Fatalf("UsageForIP returned error: %v", err)
}
if usage.UploadedBytes != 3072 {
t.Fatalf("UploadedBytes = %d, want 3072", usage.UploadedBytes)
}
if err := settings.CleanupUsage(now.AddDate(0, 0, 31), 30); err != nil {
t.Fatalf("CleanupUsage returned error: %v", err)
}
usage, err = settings.UsageForIP("127.0.0.1", now)
if err != nil {
t.Fatalf("UsageForIP returned error: %v", err)
}
if usage.UploadedBytes != 0 {
t.Fatalf("UploadedBytes after cleanup = %d, want 0", usage.UploadedBytes)
}
}
func TestEffectiveUserPolicyUsesOverridesAndInheritance(t *testing.T) {
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.UserDailyUploadMB = 100
policy.DefaultUserStorageMB = 200
policy.UserMaxDays = 30
policy.UserDailyBoxes = 40
policy.UserActiveBoxes = 50
policy.UserStorageBackend = "local"
overrideDaily := 300.0
overrideQuota := 0.0
overrideDays := 12
overrideBackend := "bucket-1"
user := User{
ID: "user-1",
Policy: UserPolicy{
DailyUploadMB: &overrideDaily,
StorageQuotaMB: &overrideQuota,
MaxDays: &overrideDays,
StorageBackendID: &overrideBackend,
},
}
effective := settings.EffectivePolicyForUser(policy, user)
if effective.DailyUploadMB != overrideDaily || effective.MaxDays != overrideDays || effective.StorageBackendID != overrideBackend {
t.Fatalf("effective policy did not use overrides: %+v", effective)
}
if effective.StorageQuotaSet {
t.Fatalf("zero storage quota override should mean unlimited: %+v", effective)
}
if effective.DailyBoxes != policy.UserDailyBoxes || effective.ActiveBoxes != policy.UserActiveBoxes {
t.Fatalf("effective policy did not inherit box caps: %+v", effective)
}
}
func newTestSettingsService(t *testing.T) *SettingsService {
t.Helper()
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := upload.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
settings, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 512,
AnonymousDailyUploadMB: 2048,
UserDailyUploadMB: 8192,
DefaultUserStorageMB: 51200,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService returned error: %v", err)
}
return settings
}

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

View File

@@ -0,0 +1,124 @@
package services
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type localStorageBackend struct {
id string
root string
}
func (b localStorageBackend) ID() string { return b.id }
func (b localStorageBackend) Type() string { return StorageBackendLocal }
func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
path, err := b.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, body)
return err
}
func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) {
path, err := b.path(key)
if err != nil {
return StorageObject{}, err
}
source, err := os.Open(path)
if err != nil {
return StorageObject{}, err
}
stat, err := source.Stat()
if err != nil {
source.Close()
return StorageObject{}, err
}
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil
}
func (b localStorageBackend) Delete(_ context.Context, key string) error {
path, err := b.path(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error {
path, err := b.path(prefix)
if err != nil {
return err
}
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b localStorageBackend) Usage(_ context.Context) (int64, error) {
var total int64
err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if os.IsNotExist(err) {
return 0, nil
}
return total, err
}
func (b localStorageBackend) Test(ctx context.Context) error {
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func (b localStorageBackend) path(key string) (string, error) {
key = filepath.Clean(strings.TrimPrefix(key, "/"))
if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) {
return "", fmt.Errorf("invalid storage key")
}
path := filepath.Join(b.root, key)
root, err := filepath.Abs(b.root)
if err != nil {
return "", err
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid storage key")
}
return abs, nil
}

View File

@@ -0,0 +1,18 @@
package services
import "io"
type joinedReadCloser struct {
io.ReadCloser
close func()
}
func closeWith(source io.ReadCloser, close func()) io.ReadCloser {
return joinedReadCloser{ReadCloser: source, close: close}
}
func (c joinedReadCloser) Close() error {
err := c.ReadCloser.Close()
c.close()
return err
}

View File

@@ -0,0 +1,113 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"net/url"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type s3StorageBackend struct {
cfg StorageBackendConfig
client *minio.Client
}
func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) {
endpoint := normalizeS3Endpoint(cfg.Endpoint)
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
BucketLookup: s3BucketLookup(cfg.PathStyle),
})
if err != nil {
return nil, err
}
return &s3StorageBackend{cfg: cfg, client: client}, nil
}
func (b *s3StorageBackend) ID() string { return b.cfg.ID }
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
opts := minio.PutObjectOptions{ContentType: contentType}
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
return err
}
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
if err != nil {
return StorageObject{}, err
}
info, err := object.Stat()
if err != nil {
object.Close()
return StorageObject{}, err
}
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
}
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
}
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/"
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
for object := range objects {
if object.Err != nil {
return object.Err
}
if err := b.Delete(ctx, object.Key); err != nil {
return err
}
}
return nil
}
func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
var total int64
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if object.Err != nil {
return 0, object.Err
}
total += object.Size
}
return total, nil
}
func (b *s3StorageBackend) Test(ctx context.Context) error {
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
}
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func s3BucketLookup(pathStyle bool) minio.BucketLookupType {
if pathStyle {
return minio.BucketLookupPath
}
return minio.BucketLookupAuto
}
func normalizeS3Endpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
return parsed.Host
}
return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
}

View File

@@ -0,0 +1,200 @@
package services
import (
"context"
"fmt"
"io"
"os"
"path"
"sort"
"strconv"
"strings"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
type sftpStorageBackend struct {
cfg StorageBackendConfig
}
func (b sftpStorageBackend) ID() string { return b.cfg.ID }
func (b sftpStorageBackend) Type() string { return StorageBackendSFTP }
func (b sftpStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
client, closer, err := b.client()
if err != nil {
return err
}
defer closer()
if err := ctx.Err(); err != nil {
return err
}
remotePath := b.remotePath(key)
if err := client.MkdirAll(path.Dir(remotePath)); err != nil {
return err
}
target, err := client.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, body)
return err
}
func (b sftpStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
client, closer, err := b.client()
if err != nil {
return StorageObject{}, err
}
if err := ctx.Err(); err != nil {
closer()
return StorageObject{}, err
}
remotePath := b.remotePath(key)
source, err := client.Open(remotePath)
if err != nil {
closer()
return StorageObject{}, err
}
stat, err := source.Stat()
if err != nil {
source.Close()
closer()
return StorageObject{}, err
}
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
}
func (b sftpStorageBackend) Delete(ctx context.Context, key string) error {
client, closer, err := b.client()
if err != nil {
return err
}
defer closer()
if err := ctx.Err(); err != nil {
return err
}
if err := client.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b sftpStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
client, closer, err := b.client()
if err != nil {
return err
}
defer closer()
if err := ctx.Err(); err != nil {
return err
}
remotePath := b.remotePath(prefix)
if err := client.RemoveDirectory(remotePath); err == nil || os.IsNotExist(err) {
return nil
}
walker := client.Walk(remotePath)
paths := make([]string, 0)
for walker.Step() {
if walker.Err() != nil {
return walker.Err()
}
paths = append(paths, walker.Path())
}
sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) })
for _, item := range paths {
if err := client.Remove(item); err != nil {
_ = client.RemoveDirectory(item)
}
}
_ = client.RemoveDirectory(remotePath)
return nil
}
func (b sftpStorageBackend) Usage(ctx context.Context) (int64, error) {
client, closer, err := b.client()
if err != nil {
return 0, err
}
defer closer()
if err := ctx.Err(); err != nil {
return 0, err
}
var total int64
walker := client.Walk(cleanRemoteRoot(b.cfg.RemotePath))
for walker.Step() {
if walker.Err() != nil {
return 0, walker.Err()
}
info := walker.Stat()
if info != nil && !info.IsDir() {
total += info.Size()
}
}
return total, nil
}
func (b sftpStorageBackend) Test(ctx context.Context) error {
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func (b sftpStorageBackend) client() (*sftp.Client, func(), error) {
auth := make([]ssh.AuthMethod, 0, 2)
if b.cfg.PrivateKey != "" {
signer, err := ssh.ParsePrivateKey([]byte(b.cfg.PrivateKey))
if err != nil {
return nil, nil, err
}
auth = append(auth, ssh.PublicKeys(signer))
}
if b.cfg.Password != "" {
auth = append(auth, ssh.Password(b.cfg.Password))
}
if len(auth) == 0 {
return nil, nil, fmt.Errorf("sftp password or private key is required")
}
hostKeyCallback, err := b.hostKeyCallback()
if err != nil {
return nil, nil, err
}
sshClient, err := ssh.Dial("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), &ssh.ClientConfig{
User: b.cfg.Username,
Auth: auth,
HostKeyCallback: hostKeyCallback,
Timeout: 15 * time.Second,
})
if err != nil {
return nil, nil, err
}
client, err := sftp.NewClient(sshClient)
if err != nil {
sshClient.Close()
return nil, nil, err
}
return client, func() {
client.Close()
sshClient.Close()
}, nil
}
func (b sftpStorageBackend) hostKeyCallback() (ssh.HostKeyCallback, error) {
if strings.TrimSpace(b.cfg.HostKey) == "" {
return ssh.InsecureIgnoreHostKey(), nil
}
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(b.cfg.HostKey)))
if err != nil {
return nil, fmt.Errorf("invalid sftp host public key: %w", err)
}
return ssh.FixedHostKey(key), nil
}
func (b sftpStorageBackend) remotePath(key string) string {
return path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
}

View File

@@ -0,0 +1,176 @@
package services
import (
"context"
"io"
"net"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/hirochachacha/go-smb2"
)
type smbStorageBackend struct {
cfg StorageBackendConfig
}
func (b smbStorageBackend) ID() string { return b.cfg.ID }
func (b smbStorageBackend) Type() string { return StorageBackendSMB }
func (b smbStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, _ string) error {
share, closer, err := b.share()
if err != nil {
return err
}
defer closer()
if err := ctx.Err(); err != nil {
return err
}
remotePath := b.remotePath(key)
if err := share.MkdirAll(path.Dir(remotePath), 0o755); err != nil {
return err
}
target, err := share.OpenFile(remotePath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, body)
return err
}
func (b smbStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
share, closer, err := b.share()
if err != nil {
return StorageObject{}, err
}
if err := ctx.Err(); err != nil {
closer()
return StorageObject{}, err
}
source, err := share.Open(b.remotePath(key))
if err != nil {
closer()
return StorageObject{}, err
}
stat, err := source.Stat()
if err != nil {
source.Close()
closer()
return StorageObject{}, err
}
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: closeWith(source, closer)}, nil
}
func (b smbStorageBackend) Delete(ctx context.Context, key string) error {
share, closer, err := b.share()
if err != nil {
return err
}
defer closer()
if err := ctx.Err(); err != nil {
return err
}
if err := share.Remove(b.remotePath(key)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b smbStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
share, closer, err := b.share()
if err != nil {
return err
}
defer closer()
if err := ctx.Err(); err != nil {
return err
}
err = share.RemoveAll(b.remotePath(prefix))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b smbStorageBackend) Usage(ctx context.Context) (int64, error) {
share, closer, err := b.share()
if err != nil {
return 0, err
}
defer closer()
if err := ctx.Err(); err != nil {
return 0, err
}
return smbUsage(share, cleanRemoteRoot(b.cfg.RemotePath))
}
func (b smbStorageBackend) Test(ctx context.Context) error {
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func (b smbStorageBackend) share() (*smb2.Share, func(), error) {
conn, err := net.DialTimeout("tcp", b.cfg.Host+":"+strconv.Itoa(b.cfg.Port), 15*time.Second)
if err != nil {
return nil, nil, err
}
dialer := &smb2.Dialer{
Initiator: &smb2.NTLMInitiator{
User: b.cfg.Username,
Password: b.cfg.Password,
Domain: b.cfg.Domain,
},
}
session, err := dialer.Dial(conn)
if err != nil {
conn.Close()
return nil, nil, err
}
share, err := session.Mount(b.cfg.Share)
if err != nil {
session.Logoff()
conn.Close()
return nil, nil, err
}
return share, func() {
share.Umount()
session.Logoff()
conn.Close()
}, nil
}
func (b smbStorageBackend) remotePath(key string) string {
return strings.TrimPrefix(path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key)), "/")
}
func smbUsage(share *smb2.Share, root string) (int64, error) {
root = strings.TrimPrefix(root, "/")
entries, err := share.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
var total int64
for _, entry := range entries {
item := path.Join(root, entry.Name())
if entry.IsDir() {
size, err := smbUsage(share, item)
if err != nil {
return 0, err
}
total += size
continue
}
total += entry.Size()
}
return total, nil
}

View File

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

View File

@@ -0,0 +1,193 @@
package services
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
)
type webDAVStorageBackend struct {
cfg StorageBackendConfig
client *http.Client
}
func (b webDAVStorageBackend) ID() string { return b.cfg.ID }
func (b webDAVStorageBackend) Type() string { return StorageBackendWebDAV }
func (b webDAVStorageBackend) Put(ctx context.Context, key string, body io.Reader, _ int64, contentType string) error {
if err := b.mkcolParents(ctx, key); err != nil {
return err
}
request, err := b.request(ctx, http.MethodPut, key, body)
if err != nil {
return err
}
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response, err := b.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("webdav put failed: %s", response.Status)
}
return nil
}
func (b webDAVStorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
request, err := b.request(ctx, http.MethodGet, key, nil)
if err != nil {
return StorageObject{}, err
}
response, err := b.client.Do(request)
if err != nil {
return StorageObject{}, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
response.Body.Close()
return StorageObject{}, fmt.Errorf("webdav get failed: %s", response.Status)
}
modTime, _ := time.Parse(http.TimeFormat, response.Header.Get("Last-Modified"))
return StorageObject{Key: key, Size: response.ContentLength, ContentType: response.Header.Get("Content-Type"), ModTime: modTime, Body: response.Body}, nil
}
func (b webDAVStorageBackend) Delete(ctx context.Context, key string) error {
return b.deletePath(ctx, key)
}
func (b webDAVStorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
return b.deletePath(ctx, strings.TrimSuffix(prefix, "/")+"/")
}
func (b webDAVStorageBackend) Usage(ctx context.Context) (int64, error) {
request, err := b.request(ctx, "PROPFIND", "", nil)
if err != nil {
return 0, err
}
request.Header.Set("Depth", "infinity")
request.Header.Set("Content-Type", "application/xml")
response, err := b.client.Do(request)
if err != nil {
return 0, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return 0, fmt.Errorf("webdav usage failed: %s", response.Status)
}
var multi webDAVMultiStatus
if err := xml.NewDecoder(response.Body).Decode(&multi); err != nil {
return 0, err
}
var total int64
for _, item := range multi.Responses {
if item.PropStat.Prop.ResourceType.Collection != nil {
continue
}
total += item.PropStat.Prop.ContentLength
}
return total, nil
}
func (b webDAVStorageBackend) Test(ctx context.Context) error {
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func (b webDAVStorageBackend) deletePath(ctx context.Context, key string) error {
request, err := b.request(ctx, http.MethodDelete, key, nil)
if err != nil {
return err
}
response, err := b.client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode == http.StatusNotFound {
return nil
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("webdav delete failed: %s", response.Status)
}
return nil
}
func (b webDAVStorageBackend) mkcolParents(ctx context.Context, key string) error {
dir := path.Dir(cleanObjectKey(key))
if dir == "." || dir == "/" {
return nil
}
parts := strings.Split(strings.Trim(dir, "/"), "/")
current := ""
for _, part := range parts {
current = path.Join(current, part)
request, err := b.request(ctx, "MKCOL", strings.TrimSuffix(current, "/")+"/", nil)
if err != nil {
return err
}
response, err := b.client.Do(request)
if err != nil {
return err
}
response.Body.Close()
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed && response.StatusCode != http.StatusConflict {
return fmt.Errorf("webdav mkcol failed: %s", response.Status)
}
}
return nil
}
func (b webDAVStorageBackend) request(ctx context.Context, method, key string, body io.Reader) (*http.Request, error) {
endpoint := strings.TrimRight(b.cfg.Endpoint, "/")
if endpoint == "" {
return nil, fmt.Errorf("webdav url is required")
}
remote := path.Join(cleanRemoteRoot(b.cfg.RemotePath), cleanObjectKey(key))
if strings.HasSuffix(key, "/") && !strings.HasSuffix(remote, "/") {
remote += "/"
}
target := endpoint + "/" + strings.TrimLeft(remote, "/")
request, err := http.NewRequestWithContext(ctx, method, target, body)
if err != nil {
return nil, err
}
if b.cfg.Username != "" || b.cfg.Password != "" {
request.SetBasicAuth(b.cfg.Username, b.cfg.Password)
}
return request, nil
}
type webDAVMultiStatus struct {
Responses []webDAVResponse `xml:"response"`
}
type webDAVResponse struct {
PropStat webDAVPropStat `xml:"propstat"`
}
type webDAVPropStat struct {
Prop webDAVProp `xml:"prop"`
}
type webDAVProp struct {
ContentLength int64 `xml:"getcontentlength"`
ResourceType webDAVResourceType `xml:"resourcetype"`
}
type webDAVResourceType struct {
Collection *struct{} `xml:"collection"`
}
func newWebDAVStorageBackend(cfg StorageBackendConfig) webDAVStorageBackend {
return webDAVStorageBackend{cfg: cfg, client: http.DefaultClient}
}

View File

@@ -2,6 +2,8 @@ package services
import (
"archive/zip"
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
@@ -12,8 +14,10 @@ import (
"io"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
@@ -30,17 +34,27 @@ type UploadService struct {
filesDir string
db *bbolt.DB
logger *slog.Logger
storage *StorageService
}
type UploadOptions struct {
MaxDays int
ExpiresInMinutes int
MaxDownloads int
Password string
ObfuscateMetadata bool
OwnerID string
CollectionID string
SkipSizeLimit bool
CreatorIP string
StorageBackendID string
}
type Box struct {
ID string `json:"id"`
OwnerID string `json:"ownerId,omitempty"`
CollectionID string `json:"collectionId,omitempty"`
Title string `json:"title,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
MaxDownloads int `json:"maxDownloads"`
@@ -49,6 +63,8 @@ type Box struct {
PasswordHash string `json:"passwordHash,omitempty"`
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
Obfuscate bool `json:"obfuscate"`
CreatorIP string `json:"creatorIp,omitempty"`
StorageBackendID string `json:"storageBackendId,omitempty"`
Files []File `json:"files"`
}
@@ -60,6 +76,8 @@ type File struct {
ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"`
ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
UploadedAt time.Time `json:"uploadedAt"`
}
@@ -93,6 +111,7 @@ type AdminStats struct {
type AdminBox struct {
ID string
OwnerID string
CreatedAt time.Time
ExpiresAt time.Time
FileCount int
@@ -104,12 +123,15 @@ type AdminBox struct {
Expired bool
}
type UserBox struct {
Box Box
CollectionName string
TotalSizeLabel string
}
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
filesDir := filepath.Join(dataDir, "files")
dbDir := filepath.Join(dataDir, "db")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, err
}
@@ -126,6 +148,11 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
db.Close()
return nil, err
}
storage, err := NewStorageService(db, dataDir)
if err != nil {
db.Close()
return nil, err
}
return &UploadService{
maxUploadSize: maxUploadSize,
@@ -134,6 +161,7 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
filesDir: filesDir,
db: db,
logger: logger,
storage: storage,
}, nil
}
@@ -141,6 +169,10 @@ func (s *UploadService) Close() error {
return s.db.Close()
}
func (s *UploadService) DB() *bbolt.DB {
return s.db
}
func (s *UploadService) MaxUploadSize() int64 {
return s.maxUploadSize
}
@@ -149,6 +181,10 @@ func (s *UploadService) MaxUploadSizeLabel() string {
return helpers.FormatBytes(s.maxUploadSize)
}
func (s *UploadService) Storage() *StorageService {
return s.storage
}
func (s *UploadService) ValidateSize(size int64) error {
if size > s.maxUploadSize {
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
@@ -164,10 +200,20 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
opts.MaxDays = 7
}
now := time.Now().UTC()
expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour)
if opts.ExpiresInMinutes > 0 {
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
}
box := Box{
ID: randomID(10),
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
OwnerID: strings.TrimSpace(opts.OwnerID),
CollectionID: strings.TrimSpace(opts.CollectionID),
CreatorIP: strings.TrimSpace(opts.CreatorIP),
StorageBackendID: normalizeBackendID(opts.StorageBackendID),
CreatedAt: now,
ExpiresAt: expiresAt,
MaxDownloads: opts.MaxDownloads,
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
Files: make([]File, 0, len(files)),
@@ -180,15 +226,22 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
box.PasswordHash = hash
}
boxDir := filepath.Join(s.filesDir, box.ID)
if err := os.MkdirAll(boxDir, 0o755); err != nil {
backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil {
return UploadResult{}, err
}
for _, header := range files {
if !opts.SkipSizeLimit {
if err := s.ValidateSize(header.Size); err != nil {
return UploadResult{}, err
}
}
maxSize := s.maxUploadSize
if opts.SkipSizeLimit {
maxSize = 0
}
file, err := header.Open()
if err != nil {
@@ -197,13 +250,18 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
fileID := randomID(8)
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")
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, s.maxUploadSize); err != nil {
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
file.Close()
return UploadResult{}, err
}
@@ -216,6 +274,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
Size: header.Size,
ContentType: contentType,
PreviewKind: previewKind(contentType),
ObjectKey: objectKey,
UploadedAt: time.Now().UTC(),
})
}
@@ -269,6 +328,29 @@ func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
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) {
boxes, err := s.ListBoxes(0)
if err != nil {
@@ -314,6 +396,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
}
rows = append(rows, AdminBox{
ID: box.ID,
OwnerID: box.OwnerID,
CreatedAt: box.CreatedAt,
ExpiresAt: box.ExpiresAt,
FileCount: len(box.Files),
@@ -328,6 +411,97 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
return rows, nil
}
func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return nil, err
}
rows := make([]UserBox, 0)
for _, box := range boxes {
if box.OwnerID != userID {
continue
}
var size int64
for _, file := range box.Files {
size += file.Size
}
rows = append(rows, UserBox{
Box: box,
CollectionName: collectionNames[box.CollectionID],
TotalSizeLabel: helpers.FormatBytes(size),
})
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
})
return rows, nil
}
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, false)
}
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, true)
}
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
var total int64
now := time.Now().UTC()
for _, box := range boxes {
if box.OwnerID != userID {
continue
}
if activeOnly && !box.ExpiresAt.After(now) {
continue
}
for _, file := range box.Files {
total += file.Size
}
}
return total, nil
}
func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if box.OwnerID != userID {
return os.ErrPermission
}
box.Title = strings.TrimSpace(title)
return s.SaveBox(box)
}
func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if box.OwnerID != userID {
return os.ErrPermission
}
box.CollectionID = strings.TrimSpace(collectionID)
return s.SaveBox(box)
}
func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if box.OwnerID != userID {
return os.ErrPermission
}
return s.DeleteBoxWithSource(boxID, "user-delete")
}
func (s *UploadService) DeleteBox(boxID string) error {
return s.DeleteBoxWithSource(boxID, "admin")
}
@@ -344,14 +518,23 @@ func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
}
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
box, _ := s.GetBox(boxID)
if err := s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
}); err != nil {
return err
}
if box.ID != "" {
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)
return nil
}
@@ -380,6 +563,56 @@ func (s *UploadService) BoxMetadataPath(box Box) string {
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 {
return box.PasswordHash != "" && box.PasswordSalt != ""
}
@@ -445,11 +678,11 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
defer archive.Close()
for _, file := range box.Files {
path := s.FilePath(box, file)
source, err := os.Open(path)
object, err := s.OpenFileObject(context.Background(), box, file)
if err != nil {
return err
}
source := object.Body
header := &zip.FileHeader{
Name: file.Name,
@@ -473,6 +706,9 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
}
func (s *UploadService) SaveBox(box Box) error {
if box.StorageBackendID == "" {
box.StorageBackendID = StorageBackendLocal
}
data, err := json.Marshal(box)
if err != nil {
return err
@@ -518,18 +754,44 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
}
defer target.Close()
written, err := io.Copy(target, io.LimitReader(source, maxSize+1))
var written int64
if maxSize <= 0 {
written, err = io.Copy(target, source)
} else {
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
}
if err != nil {
os.Remove(path)
return err
}
if written > maxSize {
if maxSize > 0 && written > maxSize {
os.Remove(path)
return fmt.Errorf("file exceeds max upload size")
}
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 {
data := make([]byte, byteCount)
if _, err := rand.Read(data); err != nil {
@@ -567,10 +829,13 @@ func previewKind(contentType string) string {
}
func (s *UploadService) writeBoxMetadata(box Box) error {
path := s.BoxMetadataPath(box)
data, err := json.MarshalIndent(box, "", " ")
if err != nil {
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")
}

View File

@@ -2,6 +2,7 @@ package services
import (
"bytes"
"context"
"io"
"log/slog"
"mime/multipart"
@@ -10,6 +11,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestDeleteTokenVerification(t *testing.T) {
@@ -59,6 +61,309 @@ func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
}
}
func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
service := newTestUploadService(t)
active, err := service.CreateBox(testFileHeaders(t, "file", "active.txt", "active"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
if err != nil {
t.Fatalf("CreateBox active returned error: %v", err)
}
expired, err := service.CreateBox(testFileHeaders(t, "file", "expired.txt", "expired"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
if err != nil {
t.Fatalf("CreateBox expired returned error: %v", err)
}
expiredBox, err := service.GetBox(expired.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
expiredBox.ExpiresAt = time.Now().UTC().Add(-time.Hour)
if err := service.SaveBox(expiredBox); err != nil {
t.Fatalf("SaveBox returned error: %v", err)
}
activeBox, err := service.GetBox(active.BoxID)
if err != nil {
t.Fatalf("GetBox active returned error: %v", err)
}
want := activeBox.Files[0].Size
got, err := service.UserActiveStorageUsed("user-1")
if err != nil {
t.Fatalf("UserActiveStorageUsed returned error: %v", err)
}
if got != want {
t.Fatalf("UserActiveStorageUsed = %d, want %d", got, want)
}
}
func TestLocalStorageBackendAndLegacyFallback(t *testing.T) {
service := newTestUploadService(t)
result := createTestBox(t, service, "file.txt", "hello")
box := getTestBox(t, service, result.BoxID)
if service.BoxStorageBackendID(box) != StorageBackendLocal {
t.Fatalf("BoxStorageBackendID = %q", service.BoxStorageBackendID(box))
}
if box.Files[0].ObjectKey == "" {
t.Fatalf("new file did not store object key")
}
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
if err != nil {
t.Fatalf("OpenFileObject returned error: %v", err)
}
data, err := io.ReadAll(object.Body)
object.Body.Close()
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if string(data) != "hello" {
t.Fatalf("object body = %q", string(data))
}
box.StorageBackendID = ""
box.Files[0].ObjectKey = ""
object, err = service.OpenFileObject(testContext(), box, box.Files[0])
if err != nil {
t.Fatalf("legacy OpenFileObject returned error: %v", err)
}
object.Body.Close()
}
func TestContaboStorageConfigAllowsDisplayNamesWithSpaces(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderContabo,
Name: "Contabo main",
Endpoint: "https://eu2.contabostorage.com",
Region: "EU",
Bucket: "My Main Bucket",
AccessKey: "access",
SecretKey: "secret",
})
if err != nil {
t.Fatalf("CreateS3Backend returned error: %v", err)
}
if cfg.Provider != StorageProviderContabo || !cfg.UseSSL || !cfg.PathStyle {
t.Fatalf("contabo config was not normalized: %+v", cfg)
}
if cfg.Bucket != "My Main Bucket" {
t.Fatalf("bucket = %q", cfg.Bucket)
}
}
func TestSFTPStorageConfigValidation(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "NAS storage",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
RemotePath: "/srv/warpbox//",
})
if err != nil {
t.Fatalf("CreateS3Backend returned error: %v", err)
}
if cfg.Type != StorageBackendSFTP || cfg.Provider != StorageProviderSFTP {
t.Fatalf("sftp config type/provider = %+v", cfg)
}
if cfg.Port != 22 {
t.Fatalf("port = %d, want 22", cfg.Port)
}
if cfg.RemotePath != "/srv/warpbox" {
t.Fatalf("remote path = %q", cfg.RemotePath)
}
}
func TestStorageUpdateRejectsProviderMutation(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "SFTP",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderS3,
Name: "Mutated",
Endpoint: "https://s3.example.test",
Bucket: "bucket",
AccessKey: "access",
SecretKey: "secret",
UseSSL: true,
}); err == nil {
t.Fatalf("UpdateBackend allowed provider mutation")
}
stored, err := service.Storage().BackendConfig(cfg.ID)
if err != nil {
t.Fatalf("BackendConfig returned error: %v", err)
}
if stored.Provider != StorageProviderSFTP || stored.Type != StorageBackendSFTP {
t.Fatalf("provider/type mutated despite error: %+v", stored)
}
if _, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderSFTP,
Type: StorageBackendS3,
Name: "Mutated",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
}); err == nil {
t.Fatalf("UpdateBackend allowed type mutation")
}
}
func TestStorageUpdatePreservesSecretsWhenBlank(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "SFTP",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
PrivateKey: "private-key",
HostKey: "host-key",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "SFTP renamed",
Host: "files.example.test",
Username: "warpbox",
})
if err != nil {
t.Fatalf("UpdateBackend returned error: %v", err)
}
if updated.Password != "secret" || updated.PrivateKey != "private-key" || updated.HostKey != "host-key" {
t.Fatalf("blank secret fields were not preserved: %+v", updated)
}
}
func TestContaboUpdateKeepsTLSAndPathStyleLocked(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateBackend(StorageBackendConfig{
Provider: StorageProviderContabo,
Name: "Contabo",
Endpoint: "https://eu2.contabostorage.com",
Bucket: "My Main Bucket",
AccessKey: "access",
SecretKey: "secret",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
updated, err := service.Storage().UpdateBackend(cfg.ID, StorageBackendConfig{
Provider: StorageProviderContabo,
Name: "Contabo",
Endpoint: "https://eu2.contabostorage.com",
Bucket: "My Main Bucket",
AccessKey: "access",
SecretKey: "secret",
UseSSL: false,
PathStyle: false,
})
if err != nil {
t.Fatalf("UpdateBackend returned error: %v", err)
}
if !updated.UseSSL || !updated.PathStyle {
t.Fatalf("contabo TLS/path-style were not locked: %+v", updated)
}
}
func TestStorageSpeedTestRequiresConnectionAndRuns(t *testing.T) {
service := newTestUploadService(t)
if _, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall); err == nil {
t.Fatalf("StartSpeedTest allowed speed test before connection test")
}
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
t.Fatalf("TestBackend local returned error: %v", err)
}
test, err := service.Storage().StartSpeedTest(StorageBackendLocal, StorageSpeedModeSmall)
if err != nil {
t.Fatalf("StartSpeedTest returned error: %v", err)
}
service.Storage().RunSpeedTest(testContext(), test.ID)
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
if err != nil {
t.Fatalf("ListSpeedTests returned error: %v", err)
}
if len(tests) != 1 {
t.Fatalf("speed tests len = %d, want 1", len(tests))
}
got := tests[0]
if got.Status != StorageSpeedStatusDone || got.ProgressPercent != 100 || got.Stage != "complete" || got.BytesWritten == 0 || got.BytesRead == 0 || got.FilesWritten == 0 {
t.Fatalf("speed test did not complete with metrics: %+v", got)
}
}
func TestCustomStorageSpeedTestUsesRequestedFiles(t *testing.T) {
service := newTestUploadService(t)
if _, err := service.Storage().TestBackend(StorageBackendLocal); err != nil {
t.Fatalf("TestBackend local returned error: %v", err)
}
test, err := service.Storage().StartSpeedTestWithOptions(StorageBackendLocal, StorageSpeedTestOptions{
Mode: StorageSpeedModeCustom,
CustomFileCount: 3,
CustomFileSizeMB: 0.001,
})
if err != nil {
t.Fatalf("StartSpeedTestWithOptions returned error: %v", err)
}
service.Storage().RunSpeedTest(testContext(), test.ID)
tests, err := service.Storage().ListSpeedTests(StorageBackendLocal, 10)
if err != nil {
t.Fatalf("ListSpeedTests returned error: %v", err)
}
got := tests[0]
if got.Mode != StorageSpeedModeCustom || got.CustomFileCount != 3 || got.CustomFileSizeMB != 0.001 || got.FilesWritten != 3 || got.Status != StorageSpeedStatusDone {
t.Fatalf("custom speed test did not use requested files: %+v", got)
}
}
func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) {
service := newTestUploadService(t)
smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderSMB,
Name: "Office NAS",
Host: "nas.example.test",
Username: "warpbox",
Password: "secret",
Share: "uploads",
RemotePath: "/warpbox//",
})
if err != nil {
t.Fatalf("CreateS3Backend smb returned error: %v", err)
}
if smb.Type != StorageBackendSMB || smb.Provider != StorageProviderSMB || smb.Port != 445 {
t.Fatalf("smb config was not normalized: %+v", smb)
}
if smb.RemotePath != "/warpbox" {
t.Fatalf("smb remote path = %q", smb.RemotePath)
}
webdav, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderWebDAV,
Name: "Nextcloud",
Endpoint: "https://files.example.test/webdav",
Username: "warpbox",
Password: "secret",
RemotePath: "/warpbox",
})
if err != nil {
t.Fatalf("CreateS3Backend webdav returned error: %v", err)
}
if webdav.Type != StorageBackendWebDAV || webdav.Provider != StorageProviderWebDAV {
t.Fatalf("webdav config was not normalized: %+v", webdav)
}
}
func testContext() context.Context {
return context.Background()
}
func newTestUploadService(t *testing.T) *UploadService {
t.Helper()
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))

View File

@@ -10,20 +10,24 @@ import (
type Renderer struct {
templates map[string]*template.Template
appName string
appVersion string
baseURL string
}
type PageData struct {
AppName string
AppVersion string
BaseURL string
Title string
Description string
ImageURL string
CurrentYear int
CurrentUser any
CSRFToken string
Data any
}
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
func NewRenderer(templateDir, appName, appVersion, baseURL string) (*Renderer, error) {
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
if err != nil {
return nil, err
@@ -56,12 +60,14 @@ func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
return &Renderer{
templates: templates,
appName: appName,
appVersion: appVersion,
baseURL: baseURL,
}, nil
}
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
data.AppName = r.appName
data.AppVersion = r.appVersion
data.BaseURL = r.baseURL
data.CurrentYear = time.Now().Year()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -0,0 +1,525 @@
:root {
color-scheme: dark;
--background: #0b0b16;
--foreground: #f5f3ff;
--card: #15132b;
--card-foreground: #f5f3ff;
--muted: #1e1b3a;
--muted-foreground: #a8a4cf;
--accent: #2a2550;
--accent-foreground: #f5f3ff;
--border: rgba(168, 150, 255, 0.16);
--input: rgba(168, 150, 255, 0.22);
--primary: #8b5cf6;
--primary-foreground: #ffffff;
--primary-hover: #7c3aed;
--ring: #a78bfa;
--success: #5eead4;
--danger: #fb7185;
--radius: 0.875rem;
--shadow: 0 24px 70px rgba(8, 4, 32, 0.6);
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--header-bg: rgba(11, 11, 22, 0.68);
--body-bg:
radial-gradient(circle at 50% -10%, rgba(139, 92, 246, 0.18), transparent 34rem),
linear-gradient(180deg, #0b0b16 0%, #0a0918 100%);
--surface-1: rgba(139, 92, 246, 0.07);
--surface-1-hover: rgba(139, 92, 246, 0.14);
--surface-2: rgba(139, 92, 246, 0.05);
}
:root[data-theme="classic"] {
color-scheme: dark;
--background: #09090b;
--foreground: #fafafa;
--card: #18181b;
--card-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--border: rgba(255, 255, 255, 0.1);
--input: rgba(255, 255, 255, 0.15);
--primary: #f4f4f5;
--primary-foreground: #18181b;
--primary-hover: #e4e4e7;
--ring: #71717a;
--success: #86efac;
--danger: #fca5a5;
--radius: 0.625rem;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--header-bg: rgba(9, 9, 11, 0.84);
--body-bg:
radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem),
linear-gradient(180deg, #09090b 0%, #0f0f12 100%);
--surface-1: rgba(39, 39, 42, 0.42);
--surface-1-hover: rgba(39, 39, 42, 0.68);
--surface-2: rgba(39, 39, 42, 0.28);
}
:root[data-theme="retro"] {
color-scheme: light;
--background: #ffffff;
--foreground: #000000;
--card: #c0c0c0;
--card-foreground: #000000;
--muted: #c0c0c0;
--muted-foreground: #404040;
--accent: #000078;
--accent-foreground: #ffffff;
--border: #000000;
--input: #000000;
--primary: #000078;
--primary-foreground: #ffffff;
--primary-hover: #0f80cd;
--ring: #000078;
--success: #008000;
--danger: #c00000;
--radius: 0rem;
--shadow:
inset -1px -1px 0 #404040,
inset 1px 1px 0 #ffffff,
inset -2px -2px 0 #808080,
inset 2px 2px 0 #dfdfdf;
--font-sans: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
--header-bg: #c0c0c0;
--body-bg: #000000;
--surface-1: #ffffff;
--surface-1-hover: #fffff0;
--surface-2: #c0c0c0;
}
* {
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
background: var(--background);
color: var(--foreground);
}
body {
position: relative;
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
background: var(--body-bg);
}
a {
color: inherit;
}
svg {
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 10;
padding: 0.75rem 1rem;
border-radius: var(--radius);
background: var(--primary);
color: var(--primary-foreground);
}
.skip-link:focus {
top: 1rem;
}
.site-header {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--border);
background: var(--header-bg);
backdrop-filter: blur(14px);
}
.nav {
width: min(72rem, calc(100% - 2rem));
min-height: 3.5rem;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand,
.nav-links,
.footer-links,
.inline-form {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.inline-form {
margin: 0;
}
.brand {
font-weight: 650;
text-decoration: none;
}
.brand-mark {
width: 1.75rem;
height: 1.75rem;
display: grid;
place-items: center;
border-radius: calc(var(--radius) - 0.125rem);
background: var(--primary);
color: var(--primary-foreground);
font-size: 0.85rem;
font-weight: 800;
}
main {
flex: 1;
}
h1 {
margin: 0;
color: var(--foreground);
font-size: 2rem;
line-height: 1.12;
font-weight: 650;
letter-spacing: 0;
}
.file-name {
display: block;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-copy p,
.download-subtitle,
.panel-header p {
margin: 0 0 1rem 0;
color: var(--muted-foreground);
font-size: 0.95rem;
line-height: 1.5;
}
.card {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 94%, transparent);
box-shadow: var(--shadow);
}
.card-content {
padding: 1.5rem;
}
.auth-view {
width: min(28rem, calc(100% - 2rem));
min-height: calc(100vh - 7.25rem);
margin: 0 auto;
padding: 3rem 0;
display: grid;
place-items: center;
}
.auth-card {
box-shadow: var(--shadow);
}
.kicker {
margin: 0 0 0.5rem;
color: var(--muted-foreground);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.muted-copy,
.auth-alt {
color: var(--muted-foreground);
font-size: 0.9rem;
line-height: 1.5;
}
.stack-form {
display: grid;
gap: 0.9rem;
margin-top: 1rem;
}
.stack-form label,
.inline-controls label,
.collection-create label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.form-error {
margin: 0;
color: #fca5a5;
font-size: 0.86rem;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 0.55rem;
}
.checkbox-field input {
width: 1rem;
min-height: 1rem;
}
.checkbox-field span {
margin: 0;
color: var(--muted-foreground);
}
label span {
display: block;
margin-bottom: 0.4rem;
color: var(--foreground);
font-size: 0.8rem;
font-weight: 600;
}
input,
select,
button {
font: inherit;
}
input,
select {
width: 100%;
min-height: 2.25rem;
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 0.125rem);
padding: 0.45rem 0.7rem;
background: var(--background);
color: var(--foreground);
}
input::placeholder {
color: var(--muted-foreground);
}
input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.form-footer,
.result-header {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.form-footer p,
#result-meta {
margin: 0;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.button,
button {
min-height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.125rem);
padding: 0.45rem 0.85rem;
color: var(--foreground);
background: transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
.button-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.button-primary:hover {
background: var(--primary-hover);
}
.button-outline {
border-color: var(--border);
background: var(--background);
}
.button-outline:hover,
.button-ghost:hover {
background: var(--accent);
}
.button-danger {
border-color: rgba(248, 113, 113, 0.28);
background: rgba(127, 29, 29, 0.3);
color: #fecaca;
}
.button-danger:hover {
background: rgba(127, 29, 29, 0.55);
}
.button-wide {
width: 100%;
min-height: 2.75rem;
margin-top: 1.25rem;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
color: var(--muted-foreground);
}
pre {
overflow-x: auto;
margin: 0.8rem 0 0;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--background);
padding: 0.9rem;
text-align: left;
}
pre code {
display: block;
margin: 0;
overflow: visible;
white-space: pre;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 1.5rem;
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: 600;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.site-footer {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 1rem 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.theme-picker {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.theme-picker > span {
display: block;
margin: 0;
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 600;
}
.theme-picker select {
width: auto;
min-height: 1.9rem;
padding: 0.2rem 0.55rem;
border-radius: calc(var(--radius) - 0.25rem);
font-size: 0.78rem;
}
.footer-links a {
text-decoration: none;
}
.form-error {
margin: 1rem 0 0;
color: #fecaca;
font-size: 0.9rem;
}
.button-sm {
min-height: 1.85rem;
padding: 0.3rem 0.65rem;
font-size: 0.8rem;
}
/* Badge variants */
.badge-active {
background: rgba(134, 239, 172, 0.12);
color: #86efac;
}
.badge-disabled {
background: rgba(252, 165, 165, 0.1);
color: #fca5a5;
}
.badge-expired {
opacity: 0.55;
}
/* Nav username indicator in header */
.nav-username {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,281 @@
.app-shell {
width: min(86rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0;
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
gap: 1.5rem;
}
.app-sidebar {
position: sticky;
top: 5rem;
align-self: start;
display: grid;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(24, 24, 27, 0.58);
}
.sidebar-link {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.62rem 0.75rem;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--muted-foreground);
text-decoration: none;
}
.sidebar-link:hover,
.sidebar-link.is-active {
border-color: var(--border);
background: var(--muted);
color: var(--foreground);
}
.admin-shell .app-sidebar {
border-color: rgba(125, 211, 252, 0.28);
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
}
.admin-shell .sidebar-link.is-active {
border-color: rgba(125, 211, 252, 0.42);
background: rgba(14, 116, 144, 0.24);
}
.admin-shell .kicker {
color: #7dd3fc;
}
.sidebar-logout {
display: grid;
margin: 0.75rem 0 0;
}
.sidebar-logout .button {
width: 100%;
}
.collection-create {
display: grid;
gap: 0.6rem;
margin-top: 1rem;
}
.app-main {
min-width: 0;
display: grid;
gap: 1rem;
}
.settings-stack {
display: grid;
gap: 1rem;
max-width: 44rem;
}
.settings-panel {
box-shadow: none;
}
.compact-upload .drop-zone {
min-height: 11rem;
}
.dashboard-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.collection-tabs,
.inline-controls {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.65rem;
}
.inline-controls input,
.inline-controls select {
min-width: 15rem;
}
.compact-input {
width: 10rem;
}
.settings-form {
display: grid;
gap: 1.5rem;
}
.settings-form-narrow {
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.settings-form label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.settings-form .checkbox-field {
grid-column: 1 / -1;
}
.settings-form button {
justify-self: start;
}
/* Tab navigation */
.tabs-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.tab-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
}
.tab {
display: inline-flex;
align-items: center;
height: 2rem;
padding: 0 0.75rem;
border-radius: 999px;
border: 1px solid transparent;
color: var(--muted-foreground);
font-size: 0.84rem;
font-weight: 500;
text-decoration: none;
transition: background 120ms, color 120ms, border-color 120ms;
}
.tab:hover {
background: var(--muted);
color: var(--foreground);
}
.tab.is-active {
border-color: var(--border);
background: var(--muted);
color: var(--foreground);
font-weight: 650;
}
/* Sidebar structure */
.sidebar-sep {
height: 1px;
border: 0;
background: var(--border);
margin: 0.5rem 0;
}
.sidebar-nav {
display: grid;
gap: 0.25rem;
}
/* Collection create dropdown */
.new-collection-drop {
position: relative;
flex-shrink: 0;
}
.new-collection-drop > summary {
list-style: none;
cursor: pointer;
}
.new-collection-drop > summary::-webkit-details-marker { display: none; }
.new-collection-body {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
z-index: 10;
width: 15rem;
padding: 1rem;
background: color-mix(in srgb, var(--card) 97%, #000);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: grid;
gap: 0.65rem;
}
.new-collection-body label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
/* Copyable URL field */
.copy-field {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
}
.copy-field input {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.8rem;
color: var(--muted-foreground);
}
/* Settings sections */
.settings-section {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.settings-section-title {
grid-column: 1 / -1;
margin: 0;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
font-weight: 650;
color: var(--foreground);
}
.settings-section .checkbox-field {
grid-column: 1 / -1;
}
.settings-section label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
/* Quota form in admin users table */
.quota-form {
display: flex;
gap: 0.4rem;
align-items: center;
margin: 0;
}
.quota-form input {
width: 6.5rem;
min-width: 0;
}

View File

@@ -0,0 +1,214 @@
/*
* Revamp ("Aurora glass") flourishes.
*
* These rules only apply to the default/revamp theme. They are scoped to
* :root:not([data-theme="classic"]):not([data-theme="retro"]) so they cover both the explicit
* data-theme="revamp" attribute AND the no-JS default (no attribute), while
* never touching the classic theme. Token colours live in 00-base.css; this
* file adds the things a flat token swap can't: the animated aurora backdrop,
* frosted glass, gradient accents, glow and motion.
*/
:root:not([data-theme="classic"]):not([data-theme="retro"]) {
scroll-behavior: smooth;
}
/* Animated aurora backdrop ------------------------------------------------ */
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before {
content: "";
position: fixed;
inset: -20vmax;
z-index: -1;
pointer-events: none;
background:
radial-gradient(38vmax 38vmax at 18% 12%, rgba(99, 102, 241, 0.38), transparent 60%),
radial-gradient(34vmax 34vmax at 82% 18%, rgba(34, 211, 238, 0.26), transparent 60%),
radial-gradient(40vmax 40vmax at 70% 88%, rgba(139, 92, 246, 0.34), transparent 62%),
radial-gradient(30vmax 30vmax at 12% 82%, rgba(236, 72, 153, 0.22), transparent 60%);
filter: blur(8px) saturate(125%);
animation: aurora-drift 26s ease-in-out infinite alternate;
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
/* faint grain/vignette to keep the glow from washing out text */
background: radial-gradient(circle at 50% 40%, transparent 0, rgba(10, 9, 24, 0.55) 78%);
}
@keyframes aurora-drift {
0% {
transform: translate3d(-4%, -2%, 0) rotate(0deg) scale(1.05);
}
50% {
transform: translate3d(3%, 2%, 0) rotate(8deg) scale(1.12);
}
100% {
transform: translate3d(2%, -3%, 0) rotate(-6deg) scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before {
animation: none;
}
}
/* Frosted glass cards ----------------------------------------------------- */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .card {
background: linear-gradient(
155deg,
color-mix(in srgb, var(--card) 78%, transparent),
color-mix(in srgb, var(--card) 92%, transparent)
);
border-color: rgba(168, 150, 255, 0.18);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
}
/* Sticky header gets the same glassy treatment */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .site-header {
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
}
/* Brand mark glows */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .brand-mark {
background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee);
color: #fff;
box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45);
}
/* Headings get a soft gradient sheen */
:root:not([data-theme="classic"]):not([data-theme="retro"]) h1 {
background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* Gradient primary buttons ------------------------------------------------ */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button.is-active {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
color: #fff;
border-color: transparent;
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.38);
transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease;
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:hover {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%);
filter: brightness(1.08);
box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5);
transform: translateY(-1px);
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:active {
transform: translateY(0);
}
/* Outline / ghost buttons get a subtle lift on hover */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost {
transition: background 140ms ease, border-color 140ms ease, transform 140ms ease;
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline:hover,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost:hover {
border-color: rgba(168, 150, 255, 0.4);
transform: translateY(-1px);
}
/* Glow focus rings -------------------------------------------------------- */
:root:not([data-theme="classic"]):not([data-theme="retro"]) :focus-visible {
outline: 2px solid transparent;
box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55);
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) input:focus,
:root:not([data-theme="classic"]):not([data-theme="retro"]) select:focus {
border-color: var(--ring);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22);
}
/* Drop zone: animated, glowing -------------------------------------------- */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone {
border-color: rgba(168, 150, 255, 0.3);
background:
radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%),
var(--surface-1);
transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone:hover,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging {
border-color: #a78bfa;
box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28);
transform: translateY(-2px);
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-icon {
color: #c4b5fd;
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging .drop-icon {
animation: drop-bounce 700ms ease infinite;
}
@keyframes drop-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
/* Badges pick up a tinted glass look */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .badge {
background: rgba(139, 92, 246, 0.14);
color: #d6ccff;
border: 1px solid rgba(168, 150, 255, 0.22);
}
/* File / result rows lift on hover */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item,
:root:not([data-theme="classic"]):not([data-theme="retro"]) .result-item {
background: color-mix(in srgb, var(--card) 60%, transparent);
border-color: rgba(168, 150, 255, 0.14);
transition: border-color 140ms ease, transform 140ms ease, background 140ms ease;
}
:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item:hover {
border-color: rgba(168, 150, 255, 0.34);
transform: translateY(-1px);
}
/* Thumbnails on the download page */
:root:not([data-theme="classic"]):not([data-theme="retro"]) .file-emblem {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18));
color: #d6ccff;
border: 1px solid rgba(168, 150, 255, 0.22);
}
/* Gentle entrance for primary content cards */
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * {
animation: rise-in 420ms ease both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * {
animation: none;
}
}

View File

@@ -0,0 +1,442 @@
/*
* "retro" theme flourishes — modelled on danlegt.com.
*
* Windows 98 chrome over a black pixel-star desktop, PixeloidSans pixel font,
* crisp (non-antialiased, pixelated) rendering. Scoped entirely to
* :root[data-theme="retro"] so it never touches the other themes.
*
* CSP-safe: external stylesheet + self-hosted fonts only (font-src 'self'),
* no inline styles, no remote assets. The starfield is pure CSS so we don't
* depend on img-src for a background gif.
*/
/* Self-hosted pixel fonts (mirrored locally — GGBotNet PixeloidSans is free,
PixelOperator is CC0). ------------------------------------------------- */
@font-face {
font-family: "PixeloidSans";
src: url("/static/fonts/pixeloid_sans/PixeloidSans.ttf") format("truetype");
font-weight: normal;
font-display: swap;
}
@font-face {
font-family: "PixeloidSans";
src: url("/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf") format("truetype");
font-weight: bold;
font-display: swap;
}
@font-face {
font-family: "PixelOperatorMono";
src: url("/static/fonts/pixel_operator/PixelOperatorMono.ttf") format("truetype");
font-weight: normal;
font-display: swap;
}
@font-face {
font-family: "PixelOperatorMono";
src: url("/static/fonts/pixel_operator/PixelOperatorMono-Bold.ttf") format("truetype");
font-weight: bold;
font-display: swap;
}
/* Crisp, pixelated, non-smoothed rendering like the source site. */
:root[data-theme="retro"] {
font-smooth: never;
-webkit-font-smoothing: none;
-moz-osx-font-smoothing: unset;
}
:root[data-theme="retro"] img,
:root[data-theme="retro"] .thumb-link img,
:root[data-theme="retro"] .preview-stage img {
image-rendering: pixelated;
}
/* Square everything — Win98 had no rounded corners. */
:root[data-theme="retro"] *,
:root[data-theme="retro"] *::before,
:root[data-theme="retro"] *::after {
border-radius: 0 !important;
}
/* Black desktop with the tiled starfield mirrored from danlegt.com. */
:root[data-theme="retro"] body {
background-color: #000000;
background-image: url("/static/backgrounds/stars1.gif");
background-repeat: repeat;
background-attachment: fixed;
image-rendering: pixelated;
}
/* Selection + focus use the classic dotted/navy treatment. */
:root[data-theme="retro"] ::selection {
background: #000078;
color: #ffffff;
}
:root[data-theme="retro"] :focus-visible {
outline: 1px dotted #000000;
outline-offset: -4px;
}
/* Header: thin flat silver toolbar with a bottom bevel. */
:root[data-theme="retro"] .site-header {
background: #c0c0c0;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border-bottom: 2px solid #404040;
box-shadow: inset 0 1px 0 #ffffff;
}
:root[data-theme="retro"] .nav {
min-height: 2.1rem;
}
:root[data-theme="retro"] .site-header .button {
min-height: 1.6rem;
padding: 0.15rem 0.6rem;
}
:root[data-theme="retro"] .brand {
color: #000000;
font-size: 0.95rem;
}
:root[data-theme="retro"] .brand-mark {
width: 1.4rem;
height: 1.4rem;
background: linear-gradient(90deg, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 0.75rem;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #6f6fff;
}
/* Cards are raised silver windows with black text. */
:root[data-theme="retro"] .card {
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
background-color: #c0c0c0;
color: #000000;
border: 1px solid #000000;
box-shadow: var(--shadow);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Headings become Win98 active title bars. */
:root[data-theme="retro"] h1 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin: -0.35rem -0.35rem 1rem;
padding: 0.35rem 0.5rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0;
text-shadow: none;
}
/* Fake window control button on the right of every title bar. */
:root[data-theme="retro"] h1::after {
content: "✕";
display: grid;
place-items: center;
width: 1.15rem;
height: 1rem;
background: #c0c0c0;
color: #000000;
font-size: 0.7rem;
font-weight: 700;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
/* Links: classic blue, underlined, purple when visited. */
:root[data-theme="retro"] a:not(.button):not(.brand) {
color: #0000ee;
text-decoration: underline;
}
:root[data-theme="retro"] a:not(.button):not(.brand):visited {
color: #551a8b;
}
:root[data-theme="retro"] a:not(.button):not(.brand):hover {
color: #ee0000;
}
/* Buttons: grey beveled chunks that press in when active. */
:root[data-theme="retro"] .button,
:root[data-theme="retro"] button {
background: #c0c0c0;
color: #000000;
border: 1px solid #000000;
font-weight: 700;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .button:hover,
:root[data-theme="retro"] button:hover {
background: #d4d0c8;
}
:root[data-theme="retro"] .button:active,
:root[data-theme="retro"] button:active,
:root[data-theme="retro"] .button.is-active {
background: #c0c0c0;
box-shadow: inset 1px 1px 0 #404040, inset -1px -1px 0 #ffffff, inset 2px 2px 0 #808080, inset -2px -2px 0 #dfdfdf;
padding-top: calc(0.45rem + 1px);
padding-left: calc(0.85rem + 1px);
}
/* The primary call-to-action gets the blue title-bar gradient. */
:root[data-theme="retro"] .button-primary {
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
}
:root[data-theme="retro"] .button-primary:hover {
background: linear-gradient(to right, #0a0a9a, 80%, #1a90dd);
}
:root[data-theme="retro"] .button-danger {
background: #c0c0c0;
color: #c00000;
border-color: #000000;
}
/* Inputs and selects look sunken (inset bevel). */
:root[data-theme="retro"] input,
:root[data-theme="retro"] select,
:root[data-theme="retro"] textarea {
background: #ffffff;
color: #000000;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] input:focus,
:root[data-theme="retro"] select:focus {
outline: 1px dotted #000000;
outline-offset: -3px;
}
/* Labels inside windows read black, not muted-grey-on-grey. */
:root[data-theme="retro"] label span,
:root[data-theme="retro"] .stack-form label,
:root[data-theme="retro"] .form-footer p,
:root[data-theme="retro"] .drop-copy,
:root[data-theme="retro"] .drop-meta,
:root[data-theme="retro"] .upload-subtitle,
:root[data-theme="retro"] .download-subtitle,
:root[data-theme="retro"] .muted-copy,
:root[data-theme="retro"] .kicker,
:root[data-theme="retro"] .checkbox-field span {
color: #1a1a1a;
}
/* API / docs page: the header is a real full-width window with the intro text
inside it, and each section card gets a Win98 title bar from its <h2>. */
:root[data-theme="retro"] .docs-header {
max-width: none;
margin-bottom: 1.5rem;
padding: 1.5rem;
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
background-color: #c0c0c0;
color: #000000;
border: 1px solid #000000;
box-shadow: var(--shadow);
}
:root[data-theme="retro"] .docs-header .kicker {
display: none;
}
:root[data-theme="retro"] .docs-header p,
:root[data-theme="retro"] .docs-header code {
color: #000000;
margin-bottom: 0;
}
:root[data-theme="retro"] .docs-card h2,
:root[data-theme="retro"] .upload-options .options-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin: -1.5rem -1.5rem 1rem;
padding: 0.35rem 0.5rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 1rem;
font-weight: 700;
}
/* Make title bars flush to the window edge (a real Win98 title bar) wherever
the heading is the top of its window: the upload card, the API header, and
the API section cards. Pages where a heading sits below an icon or kicker
(download/preview/login) keep the inset heading from the base h1 rule. */
:root[data-theme="retro"] .card-content > h1:first-child,
:root[data-theme="retro"] .docs-header h1 {
margin: -1.5rem -1.5rem 1rem;
}
:root[data-theme="retro"] .docs-card h2::after,
:root[data-theme="retro"] .upload-options .options-title::after {
content: "✕";
display: grid;
place-items: center;
width: 1.15rem;
height: 1rem;
background: #c0c0c0;
color: #000000;
font-size: 0.7rem;
font-weight: 700;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
/* Drop zone: a sunken white field with a dashed navy border. */
:root[data-theme="retro"] .drop-zone {
background: #ffffff;
border: 2px dashed #000078;
box-shadow: inset 2px 2px 0 #808080, inset -2px -2px 0 #ffffff;
}
:root[data-theme="retro"] .drop-zone:hover,
:root[data-theme="retro"] .drop-zone.is-dragging {
background: #fffff0;
border-color: #0f80cd;
}
:root[data-theme="retro"] .drop-icon {
color: #000078;
}
/* The hero "Welcome" badge becomes a high-contrast blinking pixel sticker
that sits on the black desktop above the window. */
:root[data-theme="retro"] .hero-eyebrow {
background: linear-gradient(to right, #000078, 80%, #0f80cd);
border: 1px solid #000000;
color: #ffffff;
font-weight: 700;
text-transform: uppercase;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #6f6fff;
}
:root[data-theme="retro"] .hero-eyebrow::before {
content: "★ ";
color: #ffff66;
animation: retro-blink 1.1s steps(1, end) infinite;
}
:root[data-theme="retro"] .hero-eyebrow::after {
content: " ★";
color: #ffff66;
animation: retro-blink 1.1s steps(1, end) 0.55s infinite;
}
@keyframes retro-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
:root[data-theme="retro"] .hero-eyebrow::before,
:root[data-theme="retro"] .hero-eyebrow::after {
animation: none;
}
}
/* Badges become square chips. */
:root[data-theme="retro"] .badge {
background: #ffffff;
color: #000000;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080;
}
/* File / result rows: flat white with a sunken hairline. */
:root[data-theme="retro"] .download-item,
:root[data-theme="retro"] .result-item {
background: #ffffff;
color: #000000;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #dfdfdf, inset -1px -1px 0 #808080;
}
:root[data-theme="retro"] .file-main,
:root[data-theme="retro"] .download-item small,
:root[data-theme="retro"] .result-item code {
color: #000000;
}
:root[data-theme="retro"] .file-emblem {
background: #000078;
color: #ffffff;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #4a4aff;
}
/* Code blocks use the pixel mono font. */
:root[data-theme="retro"] code,
:root[data-theme="retro"] pre,
:root[data-theme="retro"] pre code {
font-family: "PixelOperatorMono", ui-monospace, monospace;
color: #000000;
}
:root[data-theme="retro"] pre {
background: #ffffff;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080;
}
/* Progress bar: blocky segmented Win98 look. */
:root[data-theme="retro"] .progress {
background: #ffffff;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080;
}
:root[data-theme="retro"] .progress span {
background: repeating-linear-gradient(90deg, #000078 0 8px, transparent 8px 10px), #0f80cd;
}
/* Chunky retro scrollbars (WebKit/Blink). */
:root[data-theme="retro"] ::-webkit-scrollbar {
width: 16px;
height: 16px;
}
:root[data-theme="retro"] ::-webkit-scrollbar-track {
background: #dfdfdf;
}
:root[data-theme="retro"] ::-webkit-scrollbar-thumb {
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
}
/* Footer sits on the black desktop: white pixel text + a wink to the old web. */
:root[data-theme="retro"] .site-footer {
color: #ffffff;
}
:root[data-theme="retro"] .site-footer a,
:root[data-theme="retro"] .footer-links a:not(.button) {
color: #66ccff;
}
:root[data-theme="retro"] .theme-picker > span {
color: #ffffff;
}
:root[data-theme="retro"] .site-footer::after {
content: "✩ Best viewed in 1024×768 ✩";
font-weight: 700;
}
@media (max-width: 720px) {
:root[data-theme="retro"] .site-footer::after {
display: none;
}
}

View File

@@ -0,0 +1,363 @@
.upload-view {
width: min(64rem, calc(100% - 2rem));
min-height: calc(100vh - 7.25rem);
margin: 0 auto;
padding: 2.5rem 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.5rem;
}
/* Two-column upload layout: drop-zone window on the left, options on the
right. Collapses to a single column on narrow screens (see 90-responsive). */
.upload-grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 1.25rem;
align-items: start;
}
.upload-main,
.upload-options {
margin: 0;
}
.options-title {
margin: 0 0 1.1rem;
font-size: 1.05rem;
font-weight: 650;
line-height: 1.2;
}
/* Stack the option fields vertically in the narrower right-hand window. */
.upload-options .option-grid {
grid-template-columns: 1fr;
margin-top: 0;
}
/* Summary + upload button sit at the bottom of the options window. */
.upload-options .form-footer {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
margin-top: 1.25rem;
}
.upload-options .form-footer .button {
width: 100%;
}
.hero-copy {
text-align: center;
}
.hero-eyebrow {
margin: 0 0 2.5rem 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
padding: 0.3rem 0.85rem;
background: var(--surface-1);
border: 1px solid var(--border);
color: var(--muted-foreground);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.upload-subtitle {
margin: 0.35rem 0 1.25rem;
color: var(--muted-foreground);
font-size: 0.95rem;
line-height: 1.5;
}
.drop-zone {
min-height: 19rem;
display: grid;
place-items: center;
align-content: center;
gap: 0.65rem;
padding: 2rem;
border: 2px dashed var(--border);
border-radius: var(--radius);
background: var(--surface-1);
text-align: center;
cursor: pointer;
transition: border-color 160ms ease, background 160ms ease;
}
.drop-zone:hover,
.drop-zone.is-dragging {
border-color: var(--primary);
background: var(--surface-1-hover);
}
.drop-zone input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
pointer-events: none;
}
.drop-icon {
width: 2.75rem;
height: 2.75rem;
display: grid;
place-items: center;
color: var(--muted-foreground);
}
.drop-icon svg {
width: 2.5rem;
height: 2.5rem;
}
.drop-title {
font-size: 1rem;
font-weight: 650;
}
.drop-copy,
.drop-meta {
color: var(--muted-foreground);
font-size: 0.9rem;
}
.drop-meta {
margin-top: 0.75rem;
font-size: 0.78rem;
}
.advanced-options {
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface-2);
padding: 0.75rem 0.9rem;
}
.advanced-options summary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--foreground);
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
}
.option-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 1rem;
}
.form-footer,
.result-header {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.form-footer p,
#result-meta {
margin: 0;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.button,
button {
min-height: 2.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.125rem);
padding: 0.45rem 0.85rem;
color: var(--foreground);
background: transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
.button-primary {
background: var(--primary);
color: var(--primary-foreground);
}
.button-primary:hover {
background: var(--primary-hover);
}
.button-outline {
border-color: var(--border);
background: var(--background);
}
.button-outline:hover,
.button-ghost:hover {
background: var(--accent);
}
.button-danger {
border-color: rgba(248, 113, 113, 0.28);
background: rgba(127, 29, 29, 0.3);
color: #fecaca;
}
.button-danger:hover {
background: rgba(127, 29, 29, 0.55);
}
.button-wide {
width: 100%;
min-height: 2.75rem;
margin-top: 1.25rem;
}
.upload-progress {
margin-top: 1rem;
}
.progress-row {
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
font-size: 0.8rem;
}
.progress {
height: 0.4rem;
margin-top: 0.55rem;
overflow: hidden;
border-radius: 999px;
background: var(--muted);
}
.progress span {
display: block;
width: 100%;
height: 100%;
background: var(--primary);
transform-origin: left center;
transform: scaleX(0);
transition: transform 180ms ease;
}
.upload-result {
border-color: rgba(244, 244, 245, 0.24);
background: rgba(244, 244, 245, 0.06);
}
.result-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 650;
}
.result-title svg {
color: var(--success);
}
.result-actions {
display: flex;
gap: 0.5rem;
}
.manage-link {
margin: 0.9rem 0 0;
color: var(--muted-foreground);
font-size: 0.78rem;
text-align: left;
}
.manage-link a {
color: var(--foreground);
font-weight: 600;
}
.result-list,
.download-list {
display: grid;
gap: 0.6rem;
margin-top: 1rem;
}
.upload-queue {
margin-top: 1rem;
}
.result-item,
.download-item {
min-width: 0;
display: flex;
align-items: center;
gap: 0.8rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--background);
padding: 0.75rem;
}
.result-item > span,
.download-item > span {
min-width: 0;
max-width: 100%;
flex: 1;
}
.result-item strong,
.download-item strong,
.result-item code,
.download-item code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-progress-side {
width: min(10rem, 32vw);
display: grid;
gap: 0.35rem;
}
.file-progress-percent {
color: var(--muted-foreground);
font-size: 0.75rem;
text-align: right;
}
.file-progress {
height: 0.35rem;
margin-top: 0;
}
.result-item small,
.download-item small,
.result-item code,
.download-item code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.25rem;
color: var(--muted-foreground);
font-size: 0.78rem;
}

View File

@@ -0,0 +1,274 @@
.download-view {
width: min(38rem, calc(100% - 2rem));
min-height: calc(100vh - 7.25rem);
margin: 0 auto;
padding: 2.5rem 0;
display: grid;
place-items: center;
}
.download-view-wide {
width: min(58rem, calc(100% - 2rem));
}
.download-card {
text-align: center;
}
.file-emblem {
width: 4rem;
height: 4rem;
margin: 0 auto 1rem;
display: grid;
place-items: center;
border-radius: var(--radius);
background: var(--muted);
color: var(--muted-foreground);
}
.file-emblem svg {
width: 1.75rem;
height: 1.75rem;
}
.badge-row {
margin-top: 1rem;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.download-item {
color: var(--foreground);
text-align: left;
text-decoration: none;
}
.view-toolbar {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.button.is-active {
background: var(--primary);
color: var(--primary-foreground);
}
.file-browser {
transition: opacity 160ms ease;
}
.file-card {
position: relative;
}
.thumb-link {
display: block;
overflow: hidden;
flex: 0 0 4.75rem;
width: 4.75rem;
aspect-ratio: 16 / 10;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--muted);
}
.thumb-link img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.file-main {
min-width: 0;
max-width: 100%;
flex: 1;
color: var(--foreground);
text-decoration: none;
}
.file-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.preview-action [hidden] {
display: none;
}
.file-browser.is-thumbs {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
.file-browser.is-thumbs .file-card {
display: grid;
min-width: 0;
align-content: start;
gap: 0.7rem;
}
.file-browser.is-thumbs .file-main {
width: 100%;
}
.file-browser.is-thumbs .thumb-link {
width: 100%;
flex-basis: auto;
}
.file-browser.is-thumbs .button {
width: 100%;
}
.file-browser.is-thumbs .file-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.file-browser.images-only .file-card:not([data-kind="image"]) {
display: none;
}
.context-menu {
position: fixed;
z-index: 30;
width: 10.75rem;
overflow: hidden;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: color-mix(in srgb, var(--card) 96%, #000);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46);
padding: 0.4rem;
}
.context-menu[hidden] {
display: none;
}
.context-menu button {
width: 100%;
min-height: 2.05rem;
justify-content: flex-start;
border-radius: calc(var(--radius) - 0.25rem);
padding: 0.42rem 0.5rem;
color: var(--foreground);
font-size: 0.8rem;
}
.context-menu button:hover,
.context-menu button:focus-visible,
.context-menu button.is-copied {
background: var(--accent);
}
.context-menu-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.1rem 0.1rem 0.2rem 0.45rem;
}
.context-menu-top small {
color: color-mix(in srgb, var(--muted-foreground) 74%, transparent);
font-size: 0.72rem;
font-weight: 600;
}
.context-menu-icons {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.context-menu-icons button {
width: 1.9rem;
min-height: 1.9rem;
padding: 0;
justify-content: center;
}
.context-menu hr {
height: 1px;
margin: 0.35rem 0.2rem;
border: 0;
background: var(--border);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.unlock-form {
margin: 1rem auto 0;
display: grid;
max-width: 22rem;
gap: 0.75rem;
}
.manage-details {
display: grid;
gap: 0.5rem;
margin: 1rem 0 0;
text-align: left;
}
.manage-details div {
display: flex;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--border);
padding: 0.45rem 0;
}
.manage-details dt,
.manage-details dd {
margin: 0;
min-width: 0;
}
.manage-details dt {
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 600;
}
.manage-details dd {
color: var(--foreground);
font-size: 0.84rem;
text-align: right;
}
.preview-stage {
overflow: hidden;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--background);
}
.preview-stage img,
.preview-stage video {
width: 100%;
max-height: 55vh;
display: block;
object-fit: contain;
}
.preview-stage audio {
width: calc(100% - 2rem);
margin: 1rem;
}

View File

@@ -0,0 +1,104 @@
.admin-view {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.docs-view {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.docs-header {
max-width: 44rem;
}
.docs-header p {
margin: 0.55rem 0 0;
color: var(--muted-foreground);
line-height: 1.55;
}
.docs-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.docs-card {
box-shadow: none;
}
.docs-card h2 {
margin: 0;
font-size: 1rem;
}
.docs-card p {
margin: 0.65rem 0 0;
color: var(--muted-foreground);
font-size: 0.88rem;
line-height: 1.55;
}
.docs-card-wide {
grid-column: 1 / -1;
}
.endpoint-list,
.field-grid {
display: grid;
gap: 0.65rem;
margin: 1rem 0 0;
}
.endpoint-list div,
.field-grid {
min-width: 0;
}
.endpoint-list div {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr);
gap: 0.75rem;
align-items: baseline;
}
.endpoint-list dt,
.endpoint-list dd {
margin: 0;
min-width: 0;
}
.endpoint-list dt,
.field-grid span {
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 700;
}
.endpoint-list dd code {
display: block;
}
.docs-steps {
margin: 0.85rem 0 0;
padding-left: 1.1rem;
color: var(--muted-foreground);
font-size: 0.88rem;
line-height: 1.6;
}
.docs-steps li + li {
margin-top: 0.35rem;
}
.field-grid {
grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr);
}
.field-grid p {
margin: 0;
}

View File

@@ -0,0 +1,190 @@
.admin-header,
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.kicker {
margin: 0 0 0.4rem;
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 1.5rem;
}
.metric-card {
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(24, 24, 27, 0.78);
padding: 1rem;
}
.metric-card span,
.table-header p {
display: block;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.metric-card strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.4rem;
color: var(--foreground);
font-size: 1.35rem;
}
.metric-card span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-edit-metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-table-card {
margin-top: 1rem;
}
.table-header h2 {
margin: 0;
font-size: 1.05rem;
}
.table-header p {
margin: 0.3rem 0 0;
}
.admin-table-wrap {
overflow-x: auto;
margin-top: 1rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid var(--border);
padding: 0.75rem;
text-align: left;
vertical-align: middle;
}
.admin-table th {
color: var(--muted-foreground);
font-weight: 650;
}
.table-actions {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem;
}
.table-actions form {
margin: 0;
}
/* Inline row edit (details/summary in table cells) */
.row-edit {
margin-top: 0.35rem;
}
.row-edit > summary {
display: inline-flex;
align-items: center;
color: var(--muted-foreground);
font-size: 0.72rem;
cursor: pointer;
list-style: none;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
opacity: 0.75;
}
.row-edit > summary::-webkit-details-marker { display: none; }
.row-edit[open] > summary {
opacity: 1;
}
.row-edit-form {
display: flex;
gap: 0.4rem;
align-items: center;
margin-top: 0.4rem;
}
.row-edit-form input,
.row-edit-form select {
width: auto;
flex: 1;
min-width: 8rem;
min-height: 1.9rem;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
}
.storage-edit-form {
width: min(34rem, calc(100vw - 2rem));
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
gap: 0.6rem;
padding: 0.85rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
box-shadow: none;
}
.storage-edit-form label {
display: grid;
gap: 0.25rem;
}
.storage-edit-form label span {
color: var(--muted-foreground);
font-size: 0.72rem;
}
.storage-edit-form textarea {
min-height: 5rem;
resize: vertical;
}
.storage-edit-form .checkbox-field,
.storage-edit-form button {
align-self: center;
}
@media (max-width: 720px) {
.storage-edit-form {
position: static;
grid-template-columns: 1fr;
width: 100%;
}
}

View File

@@ -0,0 +1,466 @@
/* ── Storage card UI ─────────────────────────────────────────────────────── */
.storage-stack {
display: grid;
gap: 0.85rem;
}
.storage-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 94%, transparent);
overflow: hidden;
}
.storage-card.is-local {
border-left: 3px solid rgba(125, 211, 252, 0.45);
}
.storage-card.is-editing {
border-color: rgba(125, 211, 252, 0.35);
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12);
}
.storage-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
flex-wrap: wrap;
}
.storage-card-identity {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.storage-card-icon {
display: grid;
place-items: center;
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--muted);
color: var(--muted-foreground);
}
.storage-card-icon svg {
width: 1.2rem;
height: 1.2rem;
}
.storage-card-name {
display: block;
font-size: 0.95rem;
font-weight: 650;
color: var(--foreground);
}
.storage-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.3rem;
}
.storage-card-usage {
color: var(--muted-foreground);
font-size: 0.78rem;
}
.storage-card-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
flex-wrap: wrap;
}
/* View-mode summary */
.storage-card-summary {
display: flex;
flex-wrap: wrap;
gap: 0 1.75rem;
padding: 0.65rem 1.1rem 0.9rem;
border-top: 1px solid var(--border);
}
.storage-detail {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 8rem;
}
.storage-detail > span:first-child,
.storage-detail > code:first-child {
color: var(--muted-foreground);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.storage-detail > span:last-child,
.storage-detail > code:last-child {
font-size: 0.82rem;
color: var(--foreground);
word-break: break-all;
}
.storage-detail-test > span:last-child {
font-size: 0.8rem;
}
.storage-detail-test.is-ok > span:last-child { color: #86efac; }
.storage-detail-test.is-err > span:last-child { color: #fca5a5; }
/* Edit-mode body */
.storage-card:not(.is-editing) .storage-card-body { display: none; }
.storage-card.is-editing .storage-card-summary { display: none; }
.storage-card-body {
border-top: 1px solid var(--border);
padding: 1rem 1.1rem;
}
.storage-card-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
align-items: end;
}
.storage-card-fields label {
display: grid;
gap: 0.28rem;
color: var(--muted-foreground);
font-size: 0.8rem;
}
.storage-card-fields label span {
font-size: 0.72rem;
color: var(--muted-foreground);
}
.storage-card-fields textarea {
min-height: 5rem;
resize: vertical;
}
.storage-card-fields .checkbox-field {
align-self: center;
}
.storage-card-edit-bar {
grid-column: 1 / -1;
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
}
@media (max-width: 640px) {
.storage-card-fields {
grid-template-columns: 1fr;
}
}
.storage-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.6rem;
}
.storage-type-option {
display: grid;
grid-template-rows: auto auto auto;
gap: 0.3rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--foreground);
font: inherit;
text-align: left;
cursor: pointer;
text-decoration: none;
transition: border-color 120ms ease, background 120ms ease;
}
.storage-type-option:hover {
border-color: rgba(125, 211, 252, 0.35);
background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3));
}
.storage-type-option svg {
width: 1.5rem;
height: 1.5rem;
color: var(--muted-foreground);
margin-bottom: 0.2rem;
}
.storage-type-option strong {
font-size: 0.88rem;
font-weight: 650;
}
.storage-type-option span {
font-size: 0.78rem;
color: var(--muted-foreground);
line-height: 1.4;
}
.storage-ops-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.storage-op-card {
display: grid;
gap: 0.5rem;
align-content: start;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 94%, transparent);
}
.storage-op-card strong {
color: var(--foreground);
font-size: 0.9rem;
}
.storage-op-card span {
color: var(--muted-foreground);
font-size: 0.78rem;
line-height: 1.45;
}
.storage-op-card .button {
justify-self: start;
margin-top: 0.15rem;
}
.storage-form-note {
grid-column: 1 / -1;
margin: 0;
padding: 0.7rem 0.8rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--muted);
color: var(--muted-foreground);
font-size: 0.78rem;
line-height: 1.45;
}
.storage-modal[hidden] {
display: none;
}
.storage-modal {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
}
.storage-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
.storage-modal-card {
position: relative;
z-index: 1;
width: min(30rem, 100%);
max-height: min(42rem, 90vh);
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
box-shadow: var(--shadow, 0 1rem 2.5rem rgba(0, 0, 0, 0.35));
}
.storage-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.storage-speed-form,
.storage-results-list {
display: grid;
gap: 0.65rem;
padding: 0.9rem;
}
.storage-results-page {
padding: 0;
margin-top: 1rem;
}
.storage-tests-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.storage-speed-option {
display: flex;
gap: 0.65rem;
align-items: flex-start;
padding: 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 90%, var(--muted));
cursor: pointer;
}
.storage-speed-option span {
display: grid;
gap: 0.18rem;
}
.storage-speed-option small {
color: var(--muted-foreground);
font-size: 0.72rem;
line-height: 1.35;
}
.storage-custom-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
padding: 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--muted);
}
.storage-custom-fields[hidden] {
display: none;
}
.storage-custom-fields label {
display: grid;
gap: 0.25rem;
color: var(--muted-foreground);
font-size: 0.76rem;
}
.storage-result-row {
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 92%, transparent);
}
.storage-result-row summary {
display: grid;
grid-template-columns: 1.2fr 1fr auto;
gap: 0.6rem;
align-items: center;
padding: 0.65rem 0.75rem;
cursor: pointer;
font-size: 0.78rem;
}
.storage-test-progress {
display: grid;
gap: 0.25rem;
padding: 0 0.75rem 0.65rem;
}
.storage-test-progress-bar {
height: 0.45rem;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--muted);
}
.storage-test-progress-bar span {
display: block;
height: 100%;
width: 0;
background: color-mix(in srgb, var(--primary) 70%, #86efac);
transition: width 180ms ease;
}
.storage-test-progress small {
color: var(--muted-foreground);
font-size: 0.72rem;
}
.storage-result-status {
justify-self: end;
padding: 0.12rem 0.45rem;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted-foreground);
font-size: 0.7rem;
text-transform: uppercase;
}
.storage-result-status.is-done { color: #86efac; }
.storage-result-status.is-failed { color: #fca5a5; }
.storage-result-status.is-running { color: #fde68a; }
.storage-result-detail {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
padding: 0 0.75rem 0.75rem;
}
.storage-result-detail span {
display: grid;
gap: 0.12rem;
color: var(--foreground);
font-size: 0.76rem;
min-width: 0;
}
.storage-result-detail strong {
color: var(--muted-foreground);
font-size: 0.68rem;
text-transform: uppercase;
}
.storage-result-error {
grid-column: 1 / -1;
color: #fca5a5 !important;
word-break: break-word;
}
@media (max-width: 860px) {
.storage-ops-grid {
grid-template-columns: 1fr;
}
.storage-result-row summary,
.storage-result-detail,
.storage-custom-fields {
grid-template-columns: 1fr;
}
.storage-result-status {
justify-self: start;
}
}

View File

@@ -0,0 +1,59 @@
/* ── Access tokens ───────────────────────────────────────────────────────── */
.token-create-form {
display: flex;
align-items: end;
gap: 0.65rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.token-create-form label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
flex: 1;
min-width: 14rem;
}
.token-reveal {
margin-bottom: 1rem;
padding: 0.9rem 1rem;
border: 1px solid rgba(134, 239, 172, 0.3);
border-radius: var(--radius);
background: rgba(134, 239, 172, 0.08);
}
.token-reveal-title {
margin: 0 0 0.6rem;
font-size: 0.85rem;
font-weight: 650;
color: #86efac;
}
.token-reveal-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.token-reveal-value {
flex: 1;
min-width: 0;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--background);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.82rem;
word-break: break-all;
}
.token-reveal .muted-copy {
margin: 0.6rem 0 0;
}
.token-reveal .muted-copy code {
word-break: break-all;
}

View File

@@ -0,0 +1,98 @@
@media (max-width: 720px) {
.nav-links {
display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
}
.upload-view,
.download-view {
min-height: auto;
padding: 2rem 0;
}
.upload-grid {
grid-template-columns: 1fr;
}
.option-grid,
.form-footer,
.result-header,
.site-footer {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.option-grid {
grid-template-columns: 1fr;
}
.docs-grid,
.field-grid,
.app-shell,
.settings-form {
grid-template-columns: 1fr;
}
.app-sidebar {
position: static;
}
.endpoint-list div {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.result-actions {
width: 100%;
}
.file-progress-side {
width: 100%;
}
.result-actions .button {
flex: 1;
}
h1 {
font-size: 1.65rem;
}
.drop-zone {
min-height: 15rem;
}
.admin-header,
.table-header {
flex-direction: column;
align-items: stretch;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tabs-bar {
flex-direction: column;
align-items: stretch;
}
.settings-section {
grid-template-columns: 1fr;
}
.new-collection-body {
position: static;
width: 100%;
margin-top: 0.5rem;
box-shadow: none;
}
}
@media (max-width: 640px) {
.storage-card-fields {
grid-template-columns: 1fr;
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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="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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Some files were not shown because too many files have changed in this diff Show More