22 Commits

Author SHA1 Message Date
d3b6a86753 feat(file-browser): make entire file card clickable in list view
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m45s
Improve the user experience in the file browser list view by allowing users to click anywhere on a file card to open it, rather than just the specific link.

- Add a click event listener to the file browser to handle navigation when clicking a card in list view.
- Ensure interactive elements (like buttons or inputs) inside the card do not trigger the card-wide click.
- Add `cursor: pointer` to list view file cards to indicate they are clickable.
- Update retro theme CSS to style the entire card on hover and focus.
2026-06-02 14:45:55 +03:00
cf5d8bb50d feat(ui): limit visible reactions and overhaul retro theme
- Limit the number of initially visible reactions per file to 2 and calculate the overflow count on the backend.
- Redesign the retro theme CSS to mimic a classic Windows 98 Explorer window, including title bars, toolbars, and sunken panes.
- Add local storage persistence for the file browser view preference (list vs. thumbnails).
2026-06-02 14:43:16 +03:00
8e3f783780 feat(handlers): add file icons with standard and retro variants
Introduce file icon support to the file browser. Icons are loaded on
startup and mapped based on file name and content type.

- Load file icon mappings in the App handler initialization.
- Add `HasThumbnail`, `IconURL`, and `IconRetroURL` to the file view.
- Update CSS to support displaying file icons alongside thumbnails.
- Add retro theme support to swap standard icons with pixelated retro
  variants when the retro theme is active.
2026-06-02 13:02:51 +03:00
6c87187c6d refactor(api): consolidate health check endpoints to /health
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Removes the redundant `/healthz` and `/api/v1/health` endpoints, leaving `/health` as the sole health check endpoint.

- Update router to return 404 Not Found for the removed endpoints
- Update admin log filtering to only ignore `/health`
- Remove health URL from API documentation data
- Update tests to verify `/health` returns 200 and others return 404
- Update README documentation to reflect the change
2026-06-02 11:54:38 +03:00
f628b489af feat: add emoji reaction support for files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
- Implement `ReactionService` to manage file reactions in the database.
- Add `POST /d/{boxID}/f/{fileID}/react` endpoint to handle user reactions.
- Add `GET /emoji/{pack}/{file}` endpoint to serve custom emoji assets.
- Support loading custom emoji packs dynamically from the data directory.
- Update README with instructions on configuring emoji reaction packs.
2026-06-02 11:30:33 +03:00
1ab5021667 feat(config): support large uploads with read header timeout
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Disable default read and write timeouts (set to 0s) to prevent Go from
prematurely closing connections during large multi-GB uploads.

Introduce `WARPBOX_READ_HEADER_TIMEOUT` (defaulting to 15s) to protect
against slowloris-style attacks while still allowing long-running
uploads to complete. Update documentation and example configurations
accordingly.
2026-06-01 15:23:28 +03:00
c9f865cd85 refactor(admin): use inline pixel heights for overview charts
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Refactors the admin overview dashboard charts to use inline pixel heights (up to 150px) instead of CSS variables and percentage-based heights. This provides more robust rendering and layout control.

Changes include:
- Replacing `Height` with `HeightPx` in chart bar structures.
- Rendering inline styles for height and width on charts and status bars.
- Adding fallback data attributes (`data-height-px`, `data-chart-value`, etc.) and loading a new fallback script (`25-admin-charts.js`).
- Updating and expanding test coverage to assert correct scaling and HTML rendering.
2026-06-01 12:30:59 +03:00
38afc6c34d feat(admin): exclude health check entries from admin logs
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
Filter out automated health check log entries (such as `/health`,
`/healthz`, and `/api/v1/health`) from the admin logs view. This
reduces noise in the dashboard caused by frequent container health
pings.

Also added corresponding unit tests to verify the filtering behavior.
2026-06-01 12:04:36 +03:00
9a5be44a7f refactor(admin): use CSS custom properties for bar chart heights
Refactors the admin dashboard bar charts to use CSS custom properties (`--bar-height`) instead of fragile inline `height` styles.

Changes include:
- Updating the HTML templates to pass the height as a CSS variable.
- Converting the `.bar-chart` layout from Flexbox to CSS Grid for more consistent column distribution.
- Using absolute positioning for `.bar-chart-bar` inside `.bar-chart-track`.
- Adding a Go test to verify that the dashboard renders the CSS variable and no longer uses inline height styles.
2026-06-01 12:01:39 +03:00
48722f0aab refactor(backend/handlers): use withRequestLogAttrs helper for logging
Replace manual IP logging using `uploadClientIP(r)` with the
`withRequestLogAttrs` helper function in `manage.go`. This simplifies
the log statements and standardizes the extraction of request-related
attributes.
2026-06-01 11:46:34 +03:00
94cf9531fa refactor(handlers): standardize logging using request attributes helper
- Replace manual IP logging with the `withRequestLogAttrs` helper in authentication handlers.
- Add user activity logging for API documentation and login page views.
- Clean up log calls to use variadic expansion of request attributes.
2026-06-01 11:30:38 +03:00
60d2ea0204 fix(admin): improve overview bar chart layout and alignment
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m42s
2026-06-01 11:08:24 +03:00
ffa2d9636b feat(admin): add dashboard overview charts and log pagination
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Enhance the admin panel by introducing visual overview charts for upload and storage trends, along with status bars for system metrics.

Additionally, implement pagination for the admin logs view, allowing users to navigate through log entries with configurable page sizes. Corresponding CSS styles have been added for the new charts, metrics grid, and pagination controls.
2026-06-01 04:22:38 +03:00
cc91ce120d feat(admin): allow editing boxes and deleting individual files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Introduce new admin capabilities to manage uploaded boxes and files:
- Add routes and handlers for editing boxes and deleting individual files.
- Implement `RemoveFileFromBox` in `UploadService` to delete a file's stored objects and remove it from the box (deleting the box if empty).
- Implement `AdminUpdateBox` in `UploadService` to update expiry, download limits, and clear password protection.
- Remove the unused `AdminFiles` handler.
- Add `.claude` to `.gitignore`.
2026-06-01 03:39:45 +03:00
73bd14572d feat(storage): support deleting backends and improve admin UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m41s
- Implement storage backend deletion, which automatically resets default storage settings and user-specific overrides when a backend is removed.
- Add unit tests covering the delete action and its cleanup side effects.
- Improve admin UI responsiveness, fixing table scrolling, flex wrapping, and text truncation for long storage backend names.
- Update security documentation to clarify trusted proxy configurations and explain how trusted proxies are protected from automatic bans.
2026-06-01 02:24:51 +03:00
4eacb4cde2 fix(handlers): bypass box creation limits for batched uploads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m42s
Update `createOrAppendBox` to accept the upload policy and admin status, allowing policy enforcement to be handled during the box creation/append decision process. This ensures that appending files to an existing batch does not incorrectly trigger daily or active box creation limits, as no new box is being created.

Also, add unit tests to verify that batched uploads successfully bypass both daily and active box creation caps.
2026-06-01 00:20:18 +03:00
71d9b9db7e perf(backend): optimize ban lookups and prune upload group map
- Optimize the ban matching middleware by using a read-only transaction (`db.View`) for the initial scan, avoiding the single bbolt write lock on every request when no ban matches.
- Implement periodic pruning of stale entries in the upload grouper map to prevent unbounded memory growth over time.
- Avoid redundant parsing of the `max_days` form value in the upload handler.
2026-06-01 00:12:43 +03:00
01996c0445 feat(policy): support unlimited values in user policies and box expiry
- Update user policy and user update handlers to accept -1 as an unlimited value for MaxDays, DailyBoxes, ActiveBoxes, and ShortWindowRequests.
- Introduce `optionalIntAllowUnlimited` helper and update `optionalMBAllowZero` to support -1.
- Use `boxExpiryLabel` helper across admin, dashboard, and download handlers to properly format expiration dates, supporting boxes that never expire.
2026-05-31 22:40:48 +03:00
adb1a12dfd feat(upload): support batching via header and update ShareX config
Introduce support for grouping multiple sequential file uploads into a single box using the `X-Warpbox-Batch` header. This is particularly useful for ShareX multi-file selections, which are sent as separate back-to-back requests.

Additionally, this change:
- Updates the ShareX configuration template to opt-in to batching by default.
- Switches ShareX configuration placeholders to the modern `{json:...}` format.
- Adds `thumbnailUrl` to the upload response schema and documents its usage.
2026-05-31 22:27:43 +03:00
10ed806153 feat(security): add trusted proxies and abuse event cleanup
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
- Add `WARPBOX_TRUSTED_PROXIES` configuration to restrict accepted forwarded client IP headers to specific proxy IPs/CIDRs, securing client IP resolution.
- Integrate `BanService` into the background cleanup job to automatically purge expired abuse and ban evidence events.
- Update documentation with reverse proxy security guidelines and a production systemd deployment guide.
2026-05-31 21:52:56 +03:00
2d04a42736 feat(ui): style admin shell for retro theme and add prod docker config
- Update the retro theme CSS to style the dashboard, account, and admin pages with a classic Windows 98 aesthetic (silver sidebar, bevelled tabs, sunken metric cards).
- Exclude sidebar links and tabs from default retro link styles to ensure readability.
- Add `docker-compose-prod.yml` for production deployments.
- Add `.prod.env` to `.gitignore`.
2026-05-31 21:03:00 +03:00
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
109 changed files with 8230 additions and 570 deletions

View File

@@ -27,6 +27,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60 WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s
WARPBOX_TRUSTED_PROXIES=

3
.gitignore vendored
View File

@@ -12,5 +12,8 @@ backend/static/uploads/*
.env .env
.env.* .env.*
!.env.example !.env.example
.prod.env
scripts/env/dev.env scripts/env/dev.env
docker-compose.yml docker-compose.yml
.claude

View File

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

129
README.md
View File

@@ -33,10 +33,16 @@ Upload policy defaults are also configured in megabytes and can later be changed
- `WARPBOX_SHORT_WINDOW_SECONDS=60` - `WARPBOX_SHORT_WINDOW_SECONDS=60`
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local` - `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
- `WARPBOX_USER_STORAGE_BACKEND=local` - `WARPBOX_USER_STORAGE_BACKEND=local`
- `WARPBOX_TRUSTED_PROXIES=` controls whether forwarded client IP headers are accepted only from specific proxy IPs/CIDRs. See [SECURITY_PROXY.md](./SECURITY_PROXY.md).
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
The dev script resolves that path from the repository root. The dev script resolves that path from the repository root.
Large uploads are expected to take minutes on normal home/server connections. Keep
`WARPBOX_READ_TIMEOUT=0s` and `WARPBOX_WRITE_TIMEOUT=0s` so Go does not close the connection
mid-upload; `WARPBOX_READ_HEADER_TIMEOUT=15s` still protects header reads from slowloris-style
connections.
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with `WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`. `WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
@@ -48,6 +54,37 @@ links from `/admin/users`.
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it 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. at `/admin/login` if you need to recover access without a user session.
## Emoji reaction packs
File reactions use emoji packs from the runtime data directory, not from the application code. By
default that means `./data/emoji`; if you change `WARPBOX_DATA_DIR`, use
`$WARPBOX_DATA_DIR/emoji` instead.
Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files
directly inside the pack folder:
```text
data/
├── db/
├── files/
├── logs/
└── emoji/
├── openmoji/
│ ├── 1F600.svg
│ ├── 1F44D.svg
│ └── 2764.svg
├── pixel-pack/
│ ├── happy.webp
│ ├── fire.webp
│ └── star.webp
└── custom-work/
├── approved.png
└── shipped.png
```
In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`.
Supported emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`.
For one-off Go commands, run them from the backend module: For one-off Go commands, run them from the backend module:
```bash ```bash
@@ -71,8 +108,77 @@ The compose example also works with Podman compatible compose tools. Its data vo
`./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use `./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use
`/data`, `/app/static`, and `/app/templates`. `/data`, `/app/static`, and `/app/templates`.
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks The image exposes the health endpoint: `/health`. Docker and compose healthchecks use it.
use `/health`.
## Reverse Proxy Security
Warpbox uses the resolved client IP for anonymous limits, manual bans, and automatic bans. The
default behavior trusts `X-Forwarded-For` and `X-Real-IP` so a normal Caddy reverse proxy works
without extra setup. For hardened deployments where the app port might be reachable from more than
one network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes.
## Systemd
Build the binary on the server, create a dedicated user, and keep runtime data outside the repo:
```bash
cd /opt/warpbox-dev/backend
go build -o /usr/local/bin/warpbox ./cmd/warpbox
sudo useradd --system --home /var/lib/warpbox --shell /usr/sbin/nologin warpbox
sudo mkdir -p /var/lib/warpbox /etc/warpbox
sudo chown -R warpbox:warpbox /var/lib/warpbox
sudo cp /opt/warpbox-dev/.env.example /etc/warpbox/warpbox.env
```
Example `/etc/warpbox/warpbox.env` values:
```env
WARPBOX_ENV=production
WARPBOX_ADDR=127.0.0.1:6070
WARPBOX_BASE_URL=https://warpbox.dev
WARPBOX_DATA_DIR=/var/lib/warpbox
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
```
Example `/etc/systemd/system/warpbox.service`:
```ini
[Unit]
Description=Warpbox file sharing service
After=network-online.target
Wants=network-online.target
[Service]
User=warpbox
Group=warpbox
EnvironmentFile=/etc/warpbox/warpbox.env
ExecStart=/usr/local/bin/warpbox
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/lib/warpbox
[Install]
WantedBy=multi-user.target
```
Then enable it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now warpbox
sudo systemctl status warpbox
```
Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the public internet.
## Layout ## Layout
@@ -114,7 +220,7 @@ Curl and custom uploaders can use the same endpoint:
# Terminal-friendly output: one plain box URL. # Terminal-friendly output: one plain box URL.
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
# JSON output with boxUrl, manageUrl, deleteUrl, zipUrl, and file entries. # JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
curl -F sharex=@./screenshot.png \ curl -F sharex=@./screenshot.png \
-H 'Accept: application/json' \ -H 'Accept: application/json' \
http://localhost:8080/api/v1/upload http://localhost:8080/api/v1/upload
@@ -122,6 +228,19 @@ curl -F sharex=@./screenshot.png \
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL. from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header — mint
a token under **Account → Access tokens**. The JSON response uses ShareX placeholders
`{json:boxUrl}` (URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and
`{json:error}` (error message).
### Grouping multiple files into one box (`X-Warpbox-Batch`)
By default every uploaded file becomes its own box. To put several files in a **single** box, send
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
ShareX selection — which ShareX sends as separate back-to-back requests — land as one shareable
link. The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file.
Requests without the header behave exactly as before.
## Stage 4 Accounts + Personal Boxes ## Stage 4 Accounts + Personal Boxes
@@ -138,6 +257,8 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your
user storage quota, and usage retention. user storage quota, and usage retention.
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides. - `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends. - `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and
repeated login failures. Auto-ban is off by default and configured from the admin UI.
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request - 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. 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. - Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
@@ -158,6 +279,8 @@ Warpbox keeps local runtime data under the configured data directory:
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections. - `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP - `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
for anonymous uploads and user ID for signed-in uploads. for anonymous uploads and user ID for signed-in uploads.
- `data/db/warpbox.bbolt` stores manual bans, automatic ban settings, abuse counters, and malicious
path rules.
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line. - `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
## Static Asset Policy ## Static Asset Policy

87
SECURITY_PROXY.md Normal file
View File

@@ -0,0 +1,87 @@
# Security Proxy Notes
Warpbox usually runs behind a reverse proxy such as Caddy. IP-based quotas,
manual bans, and automatic bans depend on Warpbox seeing the real client IP.
## Caddy
Use this shape when Caddy and Warpbox are on the same host:
```Caddyfile
warpbox.dev {
reverse_proxy 127.0.0.1:6070 {
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host}
}
}
```
By default, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` so simple Docker,
Podman, and systemd deployments work without extra setup. This is convenient,
but it is only safe when the Warpbox port is not directly reachable by the
public internet.
## Trusted Proxies
For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR
ranges that are allowed to provide forwarded headers. Use proxy IPs only.
```env
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.30.0.1
```
When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only
if the TCP peer address is inside one of those trusted ranges. Requests coming
directly from any other IP ignore forwarded headers and use the socket address.
Recommended values:
- Same-host Caddy with systemd: `127.0.0.1,::1`
- Docker/Podman bridge gateway: add the exact gateway IP, for example `172.30.0.1`
- Docker bridge networks: use a CIDR such as `172.16.0.0/12` only if the exact gateway changes often
- Private reverse-proxy networks: add the exact private CIDR used by the proxy
Warpbox prefers the first public address in `X-Forwarded-For` when a trusted
proxy sends a chain. Loopback addresses and trusted proxy addresses are also
protected from manual and automatic bans so a bad header setup cannot ban Caddy,
the container gateway, or Warpbox itself.
## Direct Exposure
If you expose Warpbox directly without Caddy, either leave
`WARPBOX_TRUSTED_PROXIES` empty and ensure clients cannot spoof headers at the
network edge, or set it to a value that does not include public clients. Direct
public exposure is not recommended; use a reverse proxy for TLS and request
normalization.
## Large Uploads
Multi-GB uploads must not use whole-body read/write deadlines. Keep these
Warpbox values for production unless you intentionally want a hard wall-clock
upload limit:
```env
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
```
`WARPBOX_READ_HEADER_TIMEOUT` protects request headers. `WARPBOX_READ_TIMEOUT`
and `WARPBOX_WRITE_TIMEOUT` cover the whole upload/response lifetime in Go, so
small values can cause browser errors such as `NS_ERROR_NET_INTERRUPT` during
large transfers. Upload size, daily, storage, and box limits still enforce abuse
controls independently of these timeout values.
## Ban Behavior
Active bans return:
```text
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
forbidden
```
Blocked requests are still written to the JSON logs and appear under
`/admin/logs` with `source=ban`.

View File

@@ -12,6 +12,7 @@ import (
type Config struct { type Config struct {
AppName string AppName string
AppVersion string
Environment string Environment string
Addr string Addr string
BaseURL string BaseURL string
@@ -19,9 +20,11 @@ type Config struct {
AdminToken string AdminToken string
StaticDir string StaticDir string
TemplateDir string TemplateDir string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration
IdleTimeout time.Duration IdleTimeout time.Duration
TrustedProxies []string
JobsEnabled bool JobsEnabled bool
CleanupEnabled bool CleanupEnabled bool
CleanupEvery time.Duration CleanupEvery time.Duration
@@ -54,6 +57,7 @@ type SettingsDefaults struct {
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"), AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
AppVersion: envString("APP_VERSION", "dev"),
Environment: envString("WARPBOX_ENV", "development"), Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"), Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"), BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
@@ -61,9 +65,11 @@ func Load() (Config, error) {
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""), AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")), StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")), TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second), ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second), ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second), IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true), JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true), CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour), CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
@@ -178,6 +184,21 @@ func envInt(key string, fallback int) int {
return parsed return parsed
} }
func envCSV(key string) []string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return nil
}
parts := strings.Split(value, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
values = append(values, trimmed)
}
}
return values
}
func envMegabytes(key string, fallback float64) int64 { func envMegabytes(key string, fallback float64) int64 {
value := strings.TrimSpace(os.Getenv(key)) value := strings.TrimSpace(os.Getenv(key))
if value == "" { if value == "" {

View File

@@ -1,6 +1,9 @@
package config package config
import "testing" import (
"testing"
"time"
)
func TestParseMegabytes(t *testing.T) { func TestParseMegabytes(t *testing.T) {
tests := map[string]int64{ tests := map[string]int64{
@@ -49,3 +52,20 @@ func TestEnvBool(t *testing.T) {
t.Fatalf("envBool() did not fall back to true") t.Fatalf("envBool() did not fall back to true")
} }
} }
func TestLoadDefaultsUseLargeUploadFriendlyTimeouts(t *testing.T) {
t.Setenv("WARPBOX_BASE_URL", "http://example.test")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.ReadHeaderTimeout != 15*time.Second {
t.Fatalf("ReadHeaderTimeout = %s, want 15s", cfg.ReadHeaderTimeout)
}
if cfg.ReadTimeout != 0 {
t.Fatalf("ReadTimeout = %s, want 0 for long uploads", cfg.ReadTimeout)
}
if cfg.WriteTimeout != 0 {
t.Fatalf("WriteTimeout = %s, want 0 for long uploads", cfg.WriteTimeout)
}
}

View File

@@ -3,10 +3,14 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
) )
@@ -411,6 +415,80 @@ func TestLayeredUploadLimits(t *testing.T) {
} }
} }
func TestBatchedUploadAppendBypassesDailyBoxCreationCap(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousDailyBoxes = 1
policy.AnonymousActiveBoxes = 10
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
first := multipartUploadRequest(t, "/api/v1/upload", "file", "first.txt", "hello")
first.Header.Set("Accept", "application/json")
first.Header.Set(uploadBatchHeader, "sharex-test")
firstResponse := httptest.NewRecorder()
app.Upload(firstResponse, first)
if firstResponse.Code != http.StatusCreated {
t.Fatalf("first batched status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
}
second := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
second.Header.Set("Accept", "application/json")
second.Header.Set(uploadBatchHeader, "sharex-test")
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, second)
if secondResponse.Code != http.StatusCreated {
t.Fatalf("second batched status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
third := multipartUploadRequest(t, "/api/v1/upload", "file", "third.txt", "hello")
third.Header.Set("Accept", "application/json")
thirdResponse := httptest.NewRecorder()
app.Upload(thirdResponse, third)
if thirdResponse.Code != http.StatusTooManyRequests {
t.Fatalf("non-batched status = %d, body = %s", thirdResponse.Code, thirdResponse.Body.String())
}
}
func TestBatchedUploadAppendBypassesActiveBoxCreationCap(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousDailyBoxes = 10
policy.AnonymousActiveBoxes = 1
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
first := multipartUploadRequest(t, "/api/v1/upload", "file", "first.txt", "hello")
first.Header.Set("Accept", "application/json")
first.Header.Set(uploadBatchHeader, "active-cap")
firstResponse := httptest.NewRecorder()
app.Upload(firstResponse, first)
if firstResponse.Code != http.StatusCreated {
t.Fatalf("first batched status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
}
second := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
second.Header.Set("Accept", "application/json")
second.Header.Set(uploadBatchHeader, "active-cap")
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, second)
if secondResponse.Code != http.StatusCreated {
t.Fatalf("second batched status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
third := multipartUploadRequest(t, "/api/v1/upload", "file", "third.txt", "hello")
third.Header.Set("Accept", "application/json")
thirdResponse := httptest.NewRecorder()
app.Upload(thirdResponse, third)
if thirdResponse.Code != http.StatusTooManyRequests {
t.Fatalf("non-batched status = %d, body = %s", thirdResponse.Code, thirdResponse.Body.String())
}
}
func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) { func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -569,6 +647,9 @@ func TestHomeReflectsUploadPolicySettings(t *testing.T) {
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") { if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
t.Fatalf("home did not reflect policy settings: %s", body) t.Fatalf("home did not reflect policy settings: %s", body)
} }
if !strings.Contains(body, "warpbox.dev · test ·") {
t.Fatalf("home footer did not include app version: %s", body)
}
} }
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) { func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
@@ -614,6 +695,108 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
} }
} }
func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
overview := buildAdminOverview([]services.AdminBox{{
ID: "box1",
CreatedAt: today,
TotalSize: 1024,
}}, services.AdminStats{TotalBoxes: 1, TotalFiles: 1, TotalSize: 1024})
for i, bar := range overview.UploadDays {
want := 0
if i == len(overview.UploadDays)-1 {
want = 150
}
if bar.HeightPx != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
for i, bar := range overview.StorageDays {
want := 0
if i == len(overview.StorageDays)-1 {
want = 150
}
if bar.HeightPx != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
if overview.StatusBars[0].WidthPercent != 100 {
t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent)
}
}
func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
yesterday := today.AddDate(0, 0, -1)
twoDaysAgo := today.AddDate(0, 0, -2)
boxes := []services.AdminBox{
{ID: "today-1", CreatedAt: today, TotalSize: 30},
{ID: "today-2", CreatedAt: today, TotalSize: 30},
{ID: "today-3", CreatedAt: today, TotalSize: 30},
{ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20},
{ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20},
{ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10},
}
overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1})
last := len(overview.UploadDays) - 1
if overview.UploadDays[last].HeightPx != 150 {
t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx)
}
if overview.UploadDays[last-1].HeightPx != 100 {
t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx)
}
if overview.UploadDays[last-2].HeightPx != 50 {
t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx)
}
if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 {
t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx)
}
if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 {
t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent)
}
}
func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
uploadThroughApp(t, app)
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
response := httptest.NewRecorder()
app.AdminDashboard(response, request)
if response.Code != http.StatusOK {
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `style="height: 150px"`) {
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
}
if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)
}
if !strings.Contains(body, `style="height: 0px"`) {
t.Fatalf("admin overview did not render zero pixel bars: %s", body)
}
if !strings.Contains(body, `style="width: 100%"`) {
t.Fatalf("admin overview did not render a full-width status bar: %s", body)
}
if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) {
t.Fatalf("admin overview did not render status fallback data attributes: %s", body)
}
if strings.Contains(body, "--bar-height") {
t.Fatalf("admin overview still uses css variable bar heights: %s", body)
}
if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") {
t.Fatalf("admin overview did not load chart fallback script: %s", body)
}
}
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) { func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -725,6 +908,101 @@ func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) {
} }
} }
func TestAdminStorageDeleteAction(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
Provider: services.StorageProviderWebDAV,
Name: "DAV",
Endpoint: "https://dav.example.test",
Username: "warpbox",
Password: "secret",
RemotePath: "/warpbox",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
deleteRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
deleteRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
deleteRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
deleteRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
deleteRequest.SetPathValue("backendID", cfg.ID)
deleteResponse := httptest.NewRecorder()
app.AdminDeleteStorage(deleteResponse, deleteRequest)
if deleteResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminDeleteStorage status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String())
}
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
}
}
func TestAdminStorageDeleteResetsDefaultsAndUserOverrides(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
user, err := app.authService.UserByEmail("admin@example.test")
if err != nil {
t.Fatalf("UserByEmail returned error: %v", err)
}
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
Provider: services.StorageProviderWebDAV,
Name: "DAV",
Endpoint: "https://dav.example.test",
Username: "warpbox",
Password: "secret",
RemotePath: "/warpbox",
})
if err != nil {
t.Fatalf("CreateBackend returned error: %v", err)
}
settings, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
settings.UserStorageBackend = cfg.ID
if err := app.settingsService.UpdateUploadPolicy(settings); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
if err := app.authService.SetUserStorageBackend(user.ID, cfg.ID); err != nil {
t.Fatalf("SetUserStorageBackend returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
request.SetPathValue("backendID", cfg.ID)
response := httptest.NewRecorder()
app.AdminDeleteStorage(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("AdminDeleteStorage status = %d, body = %s", response.Code, response.Body.String())
}
location := response.Header().Get("Location")
if !strings.Contains(location, "Storage+backend+deleted") || !strings.Contains(location, "cleared+1+user+overrides") {
t.Fatalf("delete redirect did not include cascade notice: %s", location)
}
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
}
nextSettings, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if nextSettings.UserStorageBackend != services.StorageBackendLocal {
t.Fatalf("UserStorageBackend = %q, want local", nextSettings.UserStorageBackend)
}
nextUser, err := app.authService.UserByID(user.ID)
if err != nil {
t.Fatalf("UserByID returned error: %v", err)
}
if nextUser.Policy.StorageBackendID != nil {
t.Fatalf("user storage override was not cleared: %+v", nextUser.Policy)
}
}
func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) { func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -798,6 +1076,200 @@ func TestAdminStorageTestingPageRendersHistory(t *testing.T) {
} }
} }
func TestAdminLogsAndBansPagesRender(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
logDir := filepath.Join(app.cfg.DataDir, "logs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
logPath := filepath.Join(logDir, "2026-05-31.log")
lines := strings.Join([]string{
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
`{"date":"2026-05-31","time":"12:36:56","source":"http","severity":"dev","code":200,"log":"http request","method":"GET","path":"/health","ip":"127.0.0.1","user_agent":"Wget"}`,
"",
}, "\n")
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
logsRequest := httptest.NewRequest(http.MethodGet, "/admin/logs?q=box123", nil)
logsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
logsResponse := httptest.NewRecorder()
app.AdminLogs(logsResponse, logsRequest)
if logsResponse.Code != http.StatusOK {
t.Fatalf("AdminLogs status = %d, body = %s", logsResponse.Code, logsResponse.Body.String())
}
logsBody := logsResponse.Body.String()
if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") {
t.Fatalf("AdminLogs missing expected log entry: %s", logsBody)
}
if strings.Contains(logsBody, "172.30.0.1:48358") {
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
}
healthRequest := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
healthRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
healthResponse := httptest.NewRecorder()
app.AdminLogs(healthResponse, healthRequest)
if healthResponse.Code != http.StatusOK {
t.Fatalf("AdminLogs health status = %d, body = %s", healthResponse.Code, healthResponse.Body.String())
}
if strings.Contains(healthResponse.Body.String(), "/health") || strings.Contains(healthResponse.Body.String(), "Wget") {
t.Fatalf("AdminLogs rendered container health ping: %s", healthResponse.Body.String())
}
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
bansResponse := httptest.NewRecorder()
app.AdminBans(bansResponse, bansRequest)
if bansResponse.Code != http.StatusOK {
t.Fatalf("AdminBans status = %d, body = %s", bansResponse.Code, bansResponse.Body.String())
}
if !strings.Contains(bansResponse.Body.String(), "Manual ban") || !strings.Contains(bansResponse.Body.String(), "Auto-ban settings") {
t.Fatalf("AdminBans missing ban controls: %s", bansResponse.Body.String())
}
}
func TestAdminCanCreateAndUnbanIPBan(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
expiresAt := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04")
request := httptest.NewRequest(http.MethodPost, "/admin/bans", strings.NewReader("target=203.0.113.90&reason=test&expires_at="+expiresAt+"&csrf_token=test-csrf"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
response := httptest.NewRecorder()
app.AdminCreateBan(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("AdminCreateBan status = %d, body = %s", response.Code, response.Body.String())
}
records, err := app.banService.ListBans()
if err != nil {
t.Fatalf("ListBans returned error: %v", err)
}
if len(records) != 1 || records[0].Normalized != "203.0.113.90" {
t.Fatalf("records = %+v", records)
}
unbanRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/"+records[0].ID+"/unban", strings.NewReader("csrf_token=test-csrf"))
unbanRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
unbanRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
unbanRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
unbanRequest.SetPathValue("banID", records[0].ID)
unbanResponse := httptest.NewRecorder()
app.AdminUnban(unbanResponse, unbanRequest)
if unbanResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminUnban status = %d, body = %s", unbanResponse.Code, unbanResponse.Body.String())
}
if _, ok, err := app.banService.Match("203.0.113.90", time.Now().UTC()); err != nil || ok {
t.Fatalf("unbanned Match = %v, %v", ok, err)
}
}
func TestAdminCanUpdateBanSettingsAndRules(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/settings", strings.NewReader("auto_ban_enabled=on&auto_ban_duration_hours=48&abuse_window_hours=12&malicious_path_threshold=2&admin_login_failure_threshold=4&user_login_failure_threshold=5&csrf_token=test-csrf"))
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
settingsResponse := httptest.NewRecorder()
app.AdminBanSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminBanSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
}
settings, err := app.banService.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
if !settings.AutoBanEnabled || settings.AutoBanDurationHours != 48 || settings.MaliciousPathThreshold != 2 {
t.Fatalf("settings = %+v", settings)
}
rulesRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/rules", strings.NewReader("patterns=%2Fcustom-one%0A%2Fcustom-two&csrf_token=test-csrf"))
rulesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rulesRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
rulesRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
rulesResponse := httptest.NewRecorder()
app.AdminBanRulesPost(rulesResponse, rulesRequest)
if rulesResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminBanRulesPost status = %d, body = %s", rulesResponse.Code, rulesResponse.Body.String())
}
if pattern, err := app.banService.MaliciousPattern("/x/custom-two"); err != nil || pattern != "/custom-two" {
t.Fatalf("MaliciousPattern = %q, %v", pattern, err)
}
}
func TestLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
settings, err := app.banService.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.UserLoginFailureThreshold = 2
if err := app.banService.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
for i := 0; i < 2; i++ {
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("email=admin@example.test&password=wrong"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.RemoteAddr = "203.0.113.91:1234"
response := httptest.NewRecorder()
app.LoginPost(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("LoginPost status = %d", response.Code)
}
}
if _, ok, err := app.banService.Match("203.0.113.91", time.Now().UTC()); err != nil || !ok {
t.Fatalf("Match after login failures = %v, %v", ok, err)
}
}
func TestAdminLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
settings, err := app.banService.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.AdminLoginFailureThreshold = 2
if err := app.banService.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
app.cfg.AdminToken = "correct-token"
for i := 0; i < 2; i++ {
request := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("token=wrong"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.RemoteAddr = "203.0.113.92:1234"
response := httptest.NewRecorder()
app.AdminLoginPost(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("AdminLoginPost status = %d", response.Code)
}
}
if _, ok, err := app.banService.Match("203.0.113.92", time.Now().UTC()); err != nil || !ok {
t.Fatalf("Match after admin login failures = %v, %v", ok, err)
}
}
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult { func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
t.Helper() t.Helper()
user, err := app.authService.UserByID(userID) user, err := app.authService.UserByID(userID)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,492 @@
package handlers
import (
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
const adminFilesDefaultPageSize = 50
var adminFilesPageSizes = []int{25, 50, 100, 200}
type adminFilesData struct {
Stats services.AdminStats
Section string
PageTitle string
Boxes []adminBoxView
Query string
Sort string
Dir string
Page int
PerPage int
PerPageOptions []int
TotalPages int
Total int
RangeFrom int
RangeTo int
Columns []adminFilesColumn
PageLinks []adminFilesPageLink
HasPrev bool
HasNext bool
PrevHref string
NextHref string
}
// adminFilesQuery captures the listing state that every paginated link must
// preserve.
type adminFilesQuery struct {
Query string
Sort string
Dir string
Per int
}
type adminFilesColumn struct {
Label string
Href string
Sorted bool
Ascending bool
}
type adminFilesPageLink struct {
Page int
Href string
Active bool
}
type adminBoxEditData struct {
Section string
PageTitle string
Box adminBoxDetail
Files []adminBoxEditFile
Notice string
Error string
}
type adminBoxDetail struct {
ID string
Owner string
CreatedAt string
ExpiresLabel string
ExpiresInput string
NeverExpires bool
MaxDownloads int
DownloadCount int
FileCount int
TotalSize string
BackendID string
Protected bool
Obfuscated bool
}
type adminBoxEditFile struct {
ID string
Name string
Size string
ContentType string
ThumbnailURL string
DownloadURL string
HasPreview bool
}
// adminFileRow is the sortable/filterable representation of a box.
type adminFileRow struct {
ID string
Owner string
CreatedAt time.Time
ExpiresAt time.Time
FileCount int
DownloadCount int
MaxDownloads int
TotalSize int64
TotalSizeLabel string
Protected bool
Expired bool
}
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
boxes, err := a.uploadService.AdminBoxes(0)
if err != nil {
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return
}
ownerCache := map[string]string{}
rows := make([]adminFileRow, 0, len(boxes))
for _, box := range boxes {
rows = append(rows, adminFileRow{
ID: box.ID,
Owner: a.boxOwnerLabel(box.OwnerID, ownerCache),
CreatedAt: box.CreatedAt,
ExpiresAt: box.ExpiresAt,
FileCount: box.FileCount,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
TotalSize: box.TotalSize,
TotalSizeLabel: box.TotalSizeLabel,
Protected: box.Protected,
Expired: box.Expired,
})
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query != "" {
needle := strings.ToLower(query)
filtered := rows[:0:0]
for _, row := range rows {
if strings.Contains(strings.ToLower(row.ID), needle) || strings.Contains(strings.ToLower(row.Owner), needle) {
filtered = append(filtered, row)
}
}
rows = filtered
}
sortKey := adminFilesSortKey(r.URL.Query().Get("sort"))
dir := r.URL.Query().Get("dir")
if dir != "asc" {
dir = "desc"
}
sortAdminFileRows(rows, sortKey, dir)
perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes)
state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage}
total := len(rows)
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
page := 1
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
page = parsed
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
views := make([]adminBoxView, 0, end-start)
for _, row := range rows[start:end] {
views = append(views, adminBoxView{
ID: row.ID,
Owner: row.Owner,
CreatedAt: row.CreatedAt.Format("Jan 2, 2006 15:04"),
ExpiresAt: boxExpiryLabel(row.ExpiresAt, "Jan 2, 2006 15:04"),
FileCount: row.FileCount,
TotalSizeLabel: row.TotalSizeLabel,
DownloadCount: row.DownloadCount,
MaxDownloads: row.MaxDownloads,
Protected: row.Protected,
Expired: row.Expired,
})
}
rangeFrom := 0
if total > 0 {
rangeFrom = start + 1
}
a.renderPage(w, r, http.StatusOK, "admin_files.html", web.PageData{
Title: "Admin files",
Description: "Manage Warpbox uploads.",
CurrentUser: a.currentPublicUser(r),
Data: adminFilesData{
Stats: stats,
Section: "files",
PageTitle: "Files",
Boxes: views,
Query: query,
Sort: sortKey,
Dir: dir,
Page: page,
PerPage: perPage,
PerPageOptions: adminFilesPageSizes,
TotalPages: totalPages,
Total: total,
RangeFrom: rangeFrom,
RangeTo: end,
Columns: adminFilesColumns(state, sortKey, dir),
PageLinks: adminFilesPageLinks(state, page, totalPages),
HasPrev: page > 1,
HasNext: page < totalPages,
PrevHref: adminFilesHref(state, page-1),
NextHref: adminFilesHref(state, page+1),
},
})
}
func (a *App) boxOwnerLabel(ownerID string, cache map[string]string) string {
if ownerID == "" {
return "Anonymous"
}
if label, ok := cache[ownerID]; ok {
return label
}
label := "User"
if user, err := a.authService.UserByID(ownerID); err == nil {
label = user.Email
}
cache[ownerID] = label
return label
}
func adminFilesSortKey(value string) string {
switch value {
case "id", "owner", "files", "size", "downloads", "expires", "created":
return value
default:
return "created"
}
}
func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
less := func(i, j int) bool {
a, b := rows[i], rows[j]
switch sortKey {
case "id":
return strings.ToLower(a.ID) < strings.ToLower(b.ID)
case "owner":
return strings.ToLower(a.Owner) < strings.ToLower(b.Owner)
case "files":
return a.FileCount < b.FileCount
case "size":
return a.TotalSize < b.TotalSize
case "downloads":
return a.DownloadCount < b.DownloadCount
case "expires":
return a.ExpiresAt.Before(b.ExpiresAt)
default:
return a.CreatedAt.Before(b.CreatedAt)
}
}
sort.SliceStable(rows, func(i, j int) bool {
if dir == "desc" {
return less(j, i)
}
return less(i, j)
})
}
func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
defs := []struct{ Key, Label string }{
{"id", "Box"},
{"owner", "Owner"},
{"files", "Files"},
{"size", "Size"},
{"downloads", "Downloads"},
{"created", "Created"},
{"expires", "Expires"},
}
columns := make([]adminFilesColumn, 0, len(defs))
for _, def := range defs {
sorted := sortKey == def.Key
nextDir := "asc"
if sorted && dir == "asc" {
nextDir = "desc"
}
colState := state
colState.Sort = def.Key
colState.Dir = nextDir
columns = append(columns, adminFilesColumn{
Label: def.Label,
Href: adminFilesHref(colState, 1),
Sorted: sorted,
Ascending: dir == "asc",
})
}
return columns
}
func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
links := make([]adminFilesPageLink, 0, 5)
const window = 2
for p := page - window; p <= page+window; p++ {
if p < 1 || p > totalPages {
continue
}
links = append(links, adminFilesPageLink{
Page: p,
Href: adminFilesHref(state, p),
Active: p == page,
})
}
return links
}
func adminFilesHref(state adminFilesQuery, page int) string {
values := url.Values{}
if state.Query != "" {
values.Set("q", state.Query)
}
if state.Sort != "" && state.Sort != "created" {
values.Set("sort", state.Sort)
}
if state.Dir != "" && state.Dir != "desc" {
values.Set("dir", state.Dir)
}
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
values.Set("per", strconv.Itoa(state.Per))
}
if page > 1 {
values.Set("page", strconv.Itoa(page))
}
if len(values) == 0 {
return "/admin/files"
}
return "/admin/files?" + values.Encode()
}
// normalizePageSize parses a requested page size, falling back to def when the
// value is missing or not one of the allowed sizes.
func normalizePageSize(raw string, def int, allowed []int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return def
}
for _, size := range allowed {
if size == parsed {
return parsed
}
}
return def
}
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return
}
var totalSize int64
files := make([]adminBoxEditFile, 0, len(box.Files))
for _, file := range box.Files {
totalSize += file.Size
files = append(files, adminBoxEditFile{
ID: file.ID,
Name: file.Name,
Size: helpers.FormatBytes(file.Size),
ContentType: file.ContentType,
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
HasPreview: file.PreviewKind == "image" || file.PreviewKind == "video",
})
}
never := neverExpires(box.ExpiresAt)
expiresInput := ""
if !never {
expiresInput = box.ExpiresAt.UTC().Format("2006-01-02T15:04")
}
cache := map[string]string{}
a.renderPage(w, r, http.StatusOK, "admin_box_edit.html", web.PageData{
Title: "Edit box",
Description: "Edit a Warpbox upload.",
CurrentUser: a.currentPublicUser(r),
Data: adminBoxEditData{
Section: "files",
PageTitle: "Edit box",
Notice: r.URL.Query().Get("notice"),
Error: r.URL.Query().Get("error"),
Files: files,
Box: adminBoxDetail{
ID: box.ID,
Owner: a.boxOwnerLabel(box.OwnerID, cache),
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04 MST"),
ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
ExpiresInput: expiresInput,
NeverExpires: never,
MaxDownloads: box.MaxDownloads,
DownloadCount: box.DownloadCount,
FileCount: len(box.Files),
TotalSize: helpers.FormatBytes(totalSize),
BackendID: a.uploadService.BoxStorageBackendID(box),
Protected: a.uploadService.IsProtected(box),
Obfuscated: box.Obfuscate,
},
},
})
}
func (a *App) AdminUpdateBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
boxID := r.PathValue("boxID")
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+read+form", http.StatusSeeOther)
return
}
var expiresAt time.Time
if r.FormValue("never_expires") == "on" {
expiresAt = time.Now().UTC().AddDate(100, 0, 0)
} else {
parsed, err := time.Parse("2006-01-02T15:04", strings.TrimSpace(r.FormValue("expires_at")))
if err != nil {
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Invalid+expiration+date", http.StatusSeeOther)
return
}
expiresAt = parsed.UTC()
}
maxDownloads := parsePositiveInt(r.FormValue("max_downloads"))
removePassword := r.FormValue("remove_password") == "on"
if err := a.uploadService.AdminUpdateBox(boxID, expiresAt, maxDownloads, removePassword); err != nil {
a.logger.Warn("admin box update failed", "source", "admin", "severity", "warn", "code", 4306, "box_id", boxID, "error", err.Error())
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+save+changes", http.StatusSeeOther)
return
}
a.logger.Info("admin box updated", "source", "admin", "severity", "user_activity", "code", 2306, "ip", uploadClientIP(r), "box_id", boxID)
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=Changes+saved", http.StatusSeeOther)
}
func (a *App) AdminDeleteBoxFile(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
boxID := r.PathValue("boxID")
fileID := r.PathValue("fileID")
boxDeleted, err := a.uploadService.RemoveFileFromBox(boxID, fileID)
if err != nil {
a.logger.Warn("admin file delete failed", "source", "admin", "severity", "warn", "code", 4305, "box_id", boxID, "file_id", fileID, "error", err.Error())
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+remove+file", http.StatusSeeOther)
return
}
a.logger.Info("admin removed box file", "source", "admin", "severity", "user_activity", "code", 2305, "ip", uploadClientIP(r), "box_id", boxID, "file_id", fileID)
if boxDeleted {
http.Redirect(w, r, "/admin/files?notice=Box+deleted+(last+file+removed)", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=File+removed", http.StatusSeeOther)
}

View File

@@ -10,29 +10,41 @@ import (
type apiDocsData struct { type apiDocsData struct {
BaseURL string BaseURL string
UploadURL string UploadURL string
HealthURL string
RequestSchemaURL string RequestSchemaURL string
ResponseSchemaURL string ResponseSchemaURL string
ShareXExamplePath string ShareXExamplePath string
ShareXExampleURL string ShareXExampleURL string
ShareXDownloadURL string ShareXDownloadURL string
ShareXFileFieldName string ShareXFileFieldName string
ShareXGroupWindow string
} }
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) { func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
user, loggedIn := a.currentUser(r)
actor := "anonymous"
if loggedIn {
actor = "user"
}
a.logger.Info("api docs viewed", withRequestLogAttrs(r,
"source", "page",
"severity", "user_activity",
"code", 2501,
"actor", actor,
"user_id", user.ID,
)...)
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
Title: "API documentation", Title: "API documentation",
Description: "Curl and ShareX upload examples for Warpbox.", Description: "Curl and ShareX upload examples for Warpbox.",
Data: apiDocsData{ Data: apiDocsData{
BaseURL: a.cfg.BaseURL, BaseURL: a.cfg.BaseURL,
UploadURL: a.cfg.BaseURL + "/api/v1/upload", UploadURL: a.cfg.BaseURL + "/api/v1/upload",
HealthURL: a.cfg.BaseURL + "/api/v1/health",
RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json", RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json",
ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json", ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json",
ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu", ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu",
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload", ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu", ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
ShareXFileFieldName: "sharex", ShareXFileFieldName: "sharex",
ShareXGroupWindow: uploadGroupWindow.String(),
}, },
}) })
} }
@@ -47,11 +59,16 @@ func (a *App) ShareXAnonymousConfig(w http.ResponseWriter, r *http.Request) {
"RequestURL": a.cfg.BaseURL + "/api/v1/upload", "RequestURL": a.cfg.BaseURL + "/api/v1/upload",
"Headers": map[string]string{ "Headers": map[string]string{
"Accept": "application/json", "Accept": "application/json",
// Group a multi-file selection (sent as back-to-back requests) into
// one box. Remove this header for one box per file.
uploadBatchHeader: "sharex",
}, },
"Body": "MultipartFormData", "Body": "MultipartFormData",
"FileFormName": "sharex", "FileFormName": "sharex",
"URL": "$json:boxUrl$", "URL": "{json:boxUrl}",
"DeletionURL": "$json:manageUrl$", "ThumbnailURL": "{json:thumbnailUrl}",
"DeletionURL": "{json:deleteUrl}",
"ErrorMessage": "{json:error}",
}) })
} }
@@ -112,8 +129,9 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
"boxId": map[string]any{"type": "string"}, "boxId": map[string]any{"type": "string"},
"boxUrl": map[string]any{"type": "string", "format": "uri"}, "boxUrl": map[string]any{"type": "string", "format": "uri"},
"zipUrl": map[string]any{"type": "string", "format": "uri"}, "zipUrl": map[string]any{"type": "string", "format": "uri"},
"thumbnailUrl": map[string]any{"type": "string", "format": "uri", "description": "Thumbnail of the most recently uploaded file (placeholder until generated)."},
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."}, "manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer POST URL for deleting this upload. Returned only at upload time."}, "deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for deleting this upload (GET or POST). Returned only at upload time."},
"expiresAt": map[string]any{"type": "string", "format": "date-time"}, "expiresAt": map[string]any{"type": "string", "format": "date-time"},
"files": map[string]any{ "files": map[string]any{
"type": "array", "type": "array",
@@ -125,6 +143,7 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
"name": map[string]any{"type": "string"}, "name": map[string]any{"type": "string"},
"size": map[string]any{"type": "string"}, "size": map[string]any{"type": "string"},
"url": map[string]any{"type": "string", "format": "uri"}, "url": map[string]any{"type": "string", "format": "uri"},
"thumbnailUrl": map[string]any{"type": "string", "format": "uri"},
}, },
}, },
}, },

View File

@@ -16,10 +16,18 @@ type App struct {
uploadService *services.UploadService uploadService *services.UploadService
authService *services.AuthService authService *services.AuthService
settingsService *services.SettingsService settingsService *services.SettingsService
reactionService *services.ReactionService
banService *services.BanService
rateLimiter *rateLimiter rateLimiter *rateLimiter
uploadGroups *uploadGrouper
fileIcons *fileIconSet
} }
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App { func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, reactionService *services.ReactionService, banService *services.BanService) *App {
fileIcons, err := loadFileIcons(cfg.StaticDir)
if err != nil {
logger.Warn("failed to load file icon map", "source", "handlers", "severity", "warn", "error", err.Error())
}
return &App{ return &App{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
@@ -27,7 +35,11 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService, uploadService: uploadService,
authService: authService, authService: authService,
settingsService: settingsService, settingsService: settingsService,
reactionService: reactionService,
banService: banService,
rateLimiter: newRateLimiter(), rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(),
fileIcons: fileIcons,
} }
} }
@@ -67,6 +79,13 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser) mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
mux.HandleFunc("GET /admin/settings", a.AdminSettings) mux.HandleFunc("GET /admin/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost) mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("GET /admin/logs", a.AdminLogs)
mux.HandleFunc("GET /admin/bans", a.AdminBans)
mux.HandleFunc("POST /admin/bans", a.AdminCreateBan)
mux.HandleFunc("POST /admin/bans/{banID}/unban", a.AdminUnban)
mux.HandleFunc("POST /admin/bans/settings", a.AdminBanSettingsPost)
mux.HandleFunc("POST /admin/bans/rules", a.AdminBanRulesPost)
mux.HandleFunc("POST /admin/bans/rules/{ruleID}/delete", a.AdminBanRuleDelete)
mux.HandleFunc("GET /admin/storage", a.AdminStorage) mux.HandleFunc("GET /admin/storage", a.AdminStorage)
mux.HandleFunc("GET /admin/storage/new", a.AdminNewStorage) mux.HandleFunc("GET /admin/storage/new", a.AdminNewStorage)
mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider) mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider)
@@ -85,7 +104,6 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage) mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage) 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}/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/{backendID}/delete", a.AdminDeleteStorage)
mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup) mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup)
mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails) mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails)
@@ -98,23 +116,35 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy) mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy)
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage) mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("GET /admin/boxes/{boxID}/edit", a.AdminEditBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/edit", a.AdminUpdateBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/files/{fileID}/delete", a.AdminDeleteBoxFile)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted) mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox) mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
// GET variant so ShareX (which issues a GET to the configured DeletionURL)
// can delete a box via its secret one-time delete token.
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip) mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /health", a.Health) mux.HandleFunc("GET /health", a.Health)
mux.HandleFunc("GET /healthz", a.Health) mux.HandleFunc("GET /healthz", notFound)
mux.HandleFunc("GET /api/v1/health", a.Health) mux.HandleFunc("GET /api/v1/health", notFound)
mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig) mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig)
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema) mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema) mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
mux.HandleFunc("POST /api/v1/upload", a.Upload) mux.HandleFunc("POST /api/v1/upload", a.Upload)
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
mux.Handle("GET /static/", a.Static()) mux.Handle("GET /static/", a.Static())
} }
func notFound(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}

View File

@@ -35,6 +35,7 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) { func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) { if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.logger.Warn("registration rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4291)...)
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."}) a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
return return
} }
@@ -44,10 +45,11 @@ func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
} }
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password")) user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
if err != nil { if err != nil {
a.logger.Warn("bootstrap registration failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4400, "email", r.FormValue("email"), "error", err.Error())...)
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
return return
} }
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID) a.logger.Info("first admin created", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)...)
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app") a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
} }
@@ -56,11 +58,13 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app", http.StatusSeeOther) http.Redirect(w, r, "/app", http.StatusSeeOther)
return return
} }
a.logger.Info("login page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2503, "actor", "anonymous")...)
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")}) 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) { func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) { if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.logger.Warn("login rate limited", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4292, "email", r.FormValue("email"))...)
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."}) a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
return return
} }
@@ -74,12 +78,13 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
} }
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password")) user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
if err != nil { if err != nil {
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email")) a.logger.Warn("login failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))...)
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next}) a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
return return
} }
a.setUserSessionCookie(w, r, token) a.setUserSessionCookie(w, r, token)
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID) a.logger.Info("user login", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)...)
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther) http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
} }
@@ -87,6 +92,9 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
if !a.validateCSRF(w, r) { if !a.validateCSRF(w, r) {
return return
} }
if user, ok := a.currentUser(r); ok {
a.logger.Info("user logout", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID)...)
}
if cookie, err := r.Cookie(userSessionCookieName); err == nil { if cookie, err := r.Cookie(userSessionCookieName); err == nil {
_ = a.authService.Logout(cookie.Value) _ = a.authService.Logout(cookie.Value)
} }
@@ -100,6 +108,7 @@ func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."}) a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return return
} }
a.logger.Info("invite page viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2504, "invite_email", invite.Email, "reset", invite.UserID != "")...)
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""}) a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
} }
@@ -107,6 +116,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token") token := r.PathValue("token")
invite, err := a.authService.InviteByToken(token) invite, err := a.authService.InviteByToken(token)
if err != nil { if err != nil {
a.logger.Warn("invite accept invalid", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4404)...)
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."}) a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return return
} }
@@ -116,10 +126,11 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
} }
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password")) user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
if err != nil { if err != nil {
a.logger.Warn("invite accept failed", withRequestLogAttrs(r, "source", "auth", "severity", "warn", "code", 4405, "invite_email", invite.Email, "error", err.Error())...)
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
return return
} }
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID) a.logger.Info("invite accepted", withRequestLogAttrs(r, "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "invite_email", invite.Email)...)
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app") a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
} }
@@ -144,6 +155,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.logger.Info("account settings viewed", withRequestLogAttrs(r, "source", "page", "severity", "user_activity", "code", 2505, "user_id", user.ID)...)
a.renderAccount(w, r, http.StatusOK, user, accountData{}) a.renderAccount(w, r, http.StatusOK, user, accountData{})
} }
@@ -161,11 +173,11 @@ func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
} }
result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name")) result, err := a.authService.CreateAPIToken(user.ID, r.FormValue("name"))
if err != nil { 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.logger.Warn("api token create failed", withRequestLogAttrs(r, "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."}) a.renderAccount(w, r, http.StatusBadRequest, user, accountData{Error: "Could not create token."})
return 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.logger.Info("api token created", withRequestLogAttrs(r, "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}) a.renderAccount(w, r, http.StatusOK, user, accountData{NewToken: result.Plaintext})
} }
@@ -175,7 +187,9 @@ func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil { 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()) a.logger.Warn("api token delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())...)
} else {
a.logger.Info("api token deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))...)
} }
http.Redirect(w, r, "/account/settings", http.StatusSeeOther) http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
} }
@@ -222,13 +236,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
return return
} }
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) { if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
a.logger.Warn("password change failed current password", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID)...)
http.Redirect(w, r, "/account/settings", http.StatusSeeOther) http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return return
} }
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil { if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
a.logger.Warn("password change failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())...)
http.Redirect(w, r, "/account/settings", http.StatusSeeOther) http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return return
} }
a.logger.Info("password changed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID)...)
http.Redirect(w, r, "/account/settings", http.StatusSeeOther) http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
} }

View File

@@ -42,6 +42,12 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.logger.Info("user dashboard viewed", withRequestLogAttrs(r,
"source", "page",
"severity", "user_activity",
"code", 2502,
"user_id", user.ID,
)...)
collections, err := a.authService.ListCollections(user.ID) collections, err := a.authService.ListCollections(user.ID)
if err != nil { if err != nil {
http.Error(w, "unable to load collections", http.StatusInternalServerError) http.Error(w, "unable to load collections", http.StatusInternalServerError)
@@ -82,7 +88,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
FileCount: len(row.Box.Files), FileCount: len(row.Box.Files),
Size: row.TotalSizeLabel, Size: row.TotalSizeLabel,
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"), CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"), ExpiresAt: boxExpiryLabel(row.Box.ExpiresAt, "Jan 2 15:04"),
URL: "/d/" + row.Box.ID, URL: "/d/" + row.Box.ID,
}) })
} }
@@ -112,7 +118,9 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
return return
} }
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil { 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()) a.logger.Warn("collection create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())...)
} else {
a.logger.Info("collection created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))...)
} }
http.Redirect(w, r, "/app", http.StatusSeeOther) http.Redirect(w, r, "/app", http.StatusSeeOther)
} }
@@ -127,9 +135,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil { if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
a.logger.Warn("owned box rename failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
a.handleUserBoxError(w, r, err) a.handleUserBoxError(w, r, err)
return return
} }
a.logger.Info("owned box renamed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
http.Redirect(w, r, "/app", http.StatusSeeOther) http.Redirect(w, r, "/app", http.StatusSeeOther)
} }
@@ -144,13 +154,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
} }
collectionID := r.FormValue("collection_id") collectionID := r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) { if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
a.logger.Warn("owned box move invalid collection", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
http.Error(w, "collection not found", http.StatusForbidden) http.Error(w, "collection not found", http.StatusForbidden)
return return
} }
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil { if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
a.logger.Warn("owned box move failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
a.handleUserBoxError(w, r, err) a.handleUserBoxError(w, r, err)
return return
} }
a.logger.Info("owned box moved", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
http.Redirect(w, r, "/app", http.StatusSeeOther) http.Redirect(w, r, "/app", http.StatusSeeOther)
} }
@@ -160,9 +173,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil { if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
a.logger.Warn("owned box delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
a.handleUserBoxError(w, r, err) a.handleUserBoxError(w, r, err)
return return
} }
a.logger.Info("owned box deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
http.Redirect(w, r, "/app", http.StatusSeeOther) http.Redirect(w, r, "/app", http.StatusSeeOther)
} }

View File

@@ -2,12 +2,15 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -26,6 +29,7 @@ type downloadPageData struct {
DownloadCount int DownloadCount int
MaxDownloads int MaxDownloads int
ExpiresLabel string ExpiresLabel string
EmojiTabs []emojiTabView
} }
type boxView struct { type boxView struct {
@@ -41,6 +45,33 @@ type fileView struct {
URL string URL string
DownloadURL string DownloadURL string
ThumbnailURL string ThumbnailURL string
HasThumbnail bool
IconURL string
IconRetroURL string
ReactURL string
Reactions []reactionView
ReactionMore int
Reacted bool
}
type reactionView struct {
EmojiID string `json:"emojiId"`
URL string `json:"url"`
Label string `json:"label"`
Count int `json:"count"`
Visible bool `json:"visible"`
}
type emojiTabView struct {
ID string
Label string
Emojis []emojiOptionView
}
type emojiOptionView struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
} }
type previewPageData struct { type previewPageData struct {
@@ -53,10 +84,12 @@ type previewPageData struct {
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID")) box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil { if err != nil {
a.logger.Warn("download page missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"))...)
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if err := a.uploadService.CanDownload(box); err != nil { if err := a.uploadService.CanDownload(box); err != nil {
a.logger.Warn("download page unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{ a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
Title: "Download unavailable", Title: "Download unavailable",
Description: "This Warpbox link is no longer available.", Description: "This Warpbox link is no longer available.",
@@ -68,15 +101,24 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return return
} }
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
visitorID := a.reactionVisitorID(w, r)
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
if err != nil {
a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...)
}
files := make([]fileView, 0, len(box.Files)) files := make([]fileView, 0, len(box.Files))
if !(locked && box.Obfuscate) { if !(locked && box.Obfuscate) {
for _, file := range box.Files { for _, file := range box.Files {
files = append(files, a.fileView(box, file)) files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID]))
} }
} }
emojiTabs, err := a.emojiTabs()
if err != nil {
a.logger.Warn("failed to load emoji tabs", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4301, "box_id", box.ID, "error", err.Error())...)
}
expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST") expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox" title := "Shared files on Warpbox"
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel) description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
if locked && box.Obfuscate { if locked && box.Obfuscate {
@@ -97,8 +139,10 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
DownloadCount: box.DownloadCount, DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads, MaxDownloads: box.MaxDownloads,
ExpiresLabel: expiresLabel, ExpiresLabel: expiresLabel,
EmojiTabs: emojiTabs,
}, },
}) })
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
} }
func plural(n int) string { func plural(n int) string {
@@ -136,6 +180,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
DownloadURL: view.DownloadURL, DownloadURL: view.DownloadURL,
}, },
}) })
a.logger.Info("file preview page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID)...)
} }
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) { func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
@@ -144,11 +189,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
return return
} }
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
a.logger.Warn("protected file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID)...)
http.Error(w, "password required", http.StatusUnauthorized) http.Error(w, "password required", http.StatusUnauthorized)
return return
} }
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1") a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...)
} }
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
@@ -196,7 +243,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
return return
} }
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) { if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID) a.logger.Warn("box unlock failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)...)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
return return
} }
@@ -209,23 +256,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
Secure: r.TLS != nil, Secure: r.TLS != nil,
Expires: box.ExpiresAt, Expires: box.ExpiresAt,
}) })
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID) a.logger.Info("box unlocked", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)...)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
} }
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) { func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID")) box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil { if err != nil {
a.logger.Warn("file request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"))...)
http.NotFound(w, r) http.NotFound(w, r)
return services.Box{}, services.File{}, false return services.Box{}, services.File{}, false
} }
if err := a.uploadService.CanDownload(box); err != nil { if err := a.uploadService.CanDownload(box); err != nil {
a.logger.Warn("file request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "error", err.Error())...)
http.Error(w, err.Error(), statusForDownloadError(err)) http.Error(w, err.Error(), statusForDownloadError(err))
return services.Box{}, services.File{}, false return services.Box{}, services.File{}, false
} }
file, err := a.uploadService.FindFile(box, r.PathValue("fileID")) file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
if err != nil { if err != nil {
a.logger.Warn("file request missing file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"))...)
http.NotFound(w, r) http.NotFound(w, r)
return services.Box{}, services.File{}, false return services.Box{}, services.File{}, false
} }
@@ -235,6 +285,7 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) { func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
object, err := a.uploadService.OpenFileObject(r.Context(), box, file) object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
if err != nil { if err != nil {
a.logger.Warn("file object missing", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -270,14 +321,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID")) box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil { if err != nil {
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if err := a.uploadService.CanDownload(box); err != nil { if err := a.uploadService.CanDownload(box); err != nil {
a.logger.Warn("zip request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
http.Error(w, err.Error(), statusForDownloadError(err)) http.Error(w, err.Error(), statusForDownloadError(err))
return return
} }
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
a.logger.Warn("protected zip download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID)...)
http.Error(w, "password required", http.StatusUnauthorized) http.Error(w, "password required", http.StatusUnauthorized)
return return
} }
@@ -293,9 +347,16 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error()) a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
} }
a.logger.Info("zip downloaded", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "files", len(box.Files))...)
} }
func (a *App) fileView(box services.Box, file services.File) fileView { func (a *App) fileView(box services.Box, file services.File) fileView {
return a.fileViewWithReactions(box, file, nil, false)
}
func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
icon := a.fileIcons.lookup(file.Name, file.ContentType)
reactionViews := a.reactionViews(reactions)
return fileView{ return fileView{
ID: file.ID, ID: file.ID,
Name: file.Name, Name: file.Name,
@@ -305,9 +366,183 @@ func (a *App) fileView(box services.Box, file services.File) fileView {
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "",
IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
Reactions: reactionViews,
ReactionMore: reactionOverflowCount(reactionViews),
Reacted: reacted,
} }
} }
func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid reaction", http.StatusBadRequest)
return
}
emojiID := strings.TrimSpace(r.FormValue("emoji_id"))
if !a.validEmojiID(emojiID) {
http.Error(w, "unknown emoji", http.StatusBadRequest)
return
}
visitorID := a.reactionVisitorID(w, r)
reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID)
if errors.Is(err, os.ErrExist) {
writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"})
return
}
if err != nil {
a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
http.Error(w, "could not save reaction", http.StatusInternalServerError)
return
}
a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...)
writeJSON(w, http.StatusCreated, map[string]any{
"reactions": a.reactionViews(reactions),
"reacted": true,
})
}
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
views := make([]reactionView, 0, len(reactions))
for index, reaction := range reactions {
views = append(views, reactionView{
EmojiID: reaction.EmojiID,
URL: emojiURL(reaction.EmojiID),
Label: emojiLabel(reaction.EmojiID),
Count: reaction.Count,
Visible: index < 2,
})
}
return views
}
func reactionOverflowCount(reactions []reactionView) int {
if len(reactions) <= 2 {
return 0
}
return len(reactions) - 2
}
func (a *App) emojiTabs() ([]emojiTabView, error) {
root := a.emojiRoot()
entries, err := os.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
tabs := make([]emojiTabView, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
tabID := entry.Name()
files, err := os.ReadDir(filepath.Join(root, tabID))
if err != nil {
return nil, err
}
tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)}
for _, file := range files {
if file.IsDir() || !isEmojiFile(file.Name()) {
continue
}
emojiID := tabID + "/" + file.Name()
tab.Emojis = append(tab.Emojis, emojiOptionView{
ID: emojiID,
URL: emojiURL(emojiID),
Label: emojiLabel(emojiID),
})
}
sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID })
if len(tab.Emojis) > 0 {
tabs = append(tabs, tab)
}
}
sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
return tabs, nil
}
func (a *App) validEmojiID(id string) bool {
id = strings.TrimSpace(id)
if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") {
return false
}
parts := strings.Split(id, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) {
return false
}
info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1]))
return err == nil && !info.IsDir()
}
func (a *App) emojiRoot() string {
return filepath.Join(a.cfg.DataDir, "emoji")
}
func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string {
const cookieName = "warpbox_reactor"
if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
return cookie.Value
}
visitorID := services.RandomPublicToken(32)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: visitorID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().AddDate(1, 0, 0),
})
return visitorID
}
func isEmojiFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
}
func emojiTabLabel(id string) string {
label := strings.NewReplacer("-", " ", "_", " ").Replace(id)
if label == "" {
return "Emoji"
}
return strings.ToUpper(label[:1]) + label[1:]
}
func emojiLabel(id string) string {
base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id))
return strings.ReplaceAll(base, "-", " ")
}
func emojiURL(id string) string {
parts := strings.Split(id, "/")
if len(parts) != 2 {
return ""
}
return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1])
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool { func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
if !a.uploadService.IsProtected(box) { if !a.uploadService.IsProtected(box) {
return true return true
@@ -323,6 +558,21 @@ func unlockCookieName(boxID string) string {
return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID) return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID)
} }
// neverExpires reports whether a box's expiry is far enough out to be treated as
// "forever" (set via the unlimited / -1 expiry option).
func neverExpires(t time.Time) bool {
return time.Until(t) > 50*365*24*time.Hour
}
// boxExpiryLabel formats a box's expiry with the given layout, rendering
// "forever" boxes as "Never" instead of a meaningless far-future date.
func boxExpiryLabel(t time.Time, layout string) string {
if neverExpires(t) {
return "Never"
}
return t.Format(layout)
}
func absoluteURL(r *http.Request, path string) string { func absoluteURL(r *http.Request, path string) string {
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return path return path

View File

@@ -13,6 +13,10 @@ type healthResponse struct {
} }
func (a *App) Health(w http.ResponseWriter, r *http.Request) { func (a *App) Health(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/health" {
http.NotFound(w, r)
return
}
helpers.WriteJSON(w, http.StatusOK, healthResponse{ helpers.WriteJSON(w, http.StatusOK, healthResponse{
Status: "ok", Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339), Time: time.Now().UTC().Format(time.RFC3339),

View File

@@ -13,9 +13,7 @@ func TestHealthRoutes(t *testing.T) {
mux := http.NewServeMux() mux := http.NewServeMux()
app.RegisterRoutes(mux) app.RegisterRoutes(mux)
for _, path := range []string{"/health", "/healthz", "/api/v1/health"} { request := httptest.NewRequest(http.MethodGet, "/health", nil)
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder() response := httptest.NewRecorder()
mux.ServeHTTP(response, request) mux.ServeHTTP(response, request)
@@ -23,6 +21,12 @@ func TestHealthRoutes(t *testing.T) {
if response.Code != http.StatusOK { if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
}) for _, path := range []string{"/healthz", "/api/v1/health"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", path, response.Code)
}
} }
} }

View File

@@ -0,0 +1,152 @@
package handlers
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileIcon holds the two icon filenames for a file type: the standard (modern)
// icon and the retro (Win98) icon. The filenames are resolved against
// static/file-icons/standard and static/file-icons/retro respectively.
type fileIcon struct {
Standard string `json:"standard"`
Retro string `json:"retro"`
}
type iconType struct {
Mime string `json:"mime"`
Standard string `json:"standard"`
Retro string `json:"retro"`
Extensions []string `json:"extensions"`
}
type iconMapFile struct {
Default iconType `json:"default"`
Types []iconType `json:"types"`
}
type mimeRule struct {
pattern string // exact mime ("application/pdf") or major prefix ("image/")
prefix bool
icon fileIcon
}
// fileIconSet is the loaded icon map: an extension lookup plus content-type
// rules and a fallback. It is built once at startup from icon-map.json.
type fileIconSet struct {
byExt map[string]fileIcon
byMime []mimeRule
fallback fileIcon
}
// loadFileIcons reads static/file-icons/icon-map.json and indexes it by
// extension and content type so icons can be assigned at render time.
func loadFileIcons(staticDir string) (*fileIconSet, error) {
data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json"))
if err != nil {
return nil, err
}
var raw iconMapFile
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
set := &fileIconSet{
byExt: make(map[string]fileIcon),
fallback: fileIcon{Standard: raw.Default.Standard, Retro: raw.Default.Retro},
}
if err := validateFileIcon(staticDir, set.fallback); err != nil {
return nil, err
}
for _, t := range raw.Types {
icon := fileIcon{Standard: t.Standard, Retro: t.Retro}
if err := validateFileIcon(staticDir, icon); err != nil {
return nil, err
}
for _, ext := range t.Extensions {
set.byExt[strings.ToLower(strings.TrimPrefix(ext, "."))] = icon
}
if t.Mime == "" {
continue
}
if strings.HasSuffix(t.Mime, "/*") {
set.byMime = append(set.byMime, mimeRule{pattern: strings.TrimSuffix(t.Mime, "*"), prefix: true, icon: icon})
} else {
set.byMime = append(set.byMime, mimeRule{pattern: strings.ToLower(t.Mime), icon: icon})
}
}
return set, nil
}
func validateFileIcon(staticDir string, icon fileIcon) error {
if icon.Standard != "" {
if err := validateFileIconPath(staticDir, "standard", icon.Standard); err != nil {
return err
}
}
if icon.Retro != "" {
if err := validateFileIconPath(staticDir, "retro", icon.Retro); err != nil {
return err
}
}
return nil
}
func validateFileIconPath(staticDir, theme, name string) error {
if strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") {
return fmt.Errorf("invalid %s file icon path %q", theme, name)
}
path := filepath.Join(staticDir, "file-icons", theme, name)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("missing %s file icon %q: %w", theme, name, err)
}
if info.IsDir() {
return fmt.Errorf("%s file icon %q is a directory", theme, name)
}
return nil
}
// lookup resolves a file's icon from its name (extension) first, falling back to
// its content type, then to the default icon. Extension wins because stored
// content types are often the generic application/octet-stream.
func (s *fileIconSet) lookup(name, contentType string) fileIcon {
if s == nil {
return fileIcon{}
}
if ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), ".")); ext != "" {
if icon, ok := s.byExt[ext]; ok {
return icon
}
}
ct := strings.ToLower(strings.TrimSpace(contentType))
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = strings.TrimSpace(ct[:i])
}
if ct != "" && ct != "application/octet-stream" {
for _, rule := range s.byMime { // exact matches first
if !rule.prefix && rule.pattern == ct {
return rule.icon
}
}
for _, rule := range s.byMime { // then major-type prefixes
if rule.prefix && strings.HasPrefix(ct, rule.pattern) {
return rule.icon
}
}
}
return s.fallback
}
// fileIconURL builds the /static URL for an icon filename in the given theme
// directory ("standard" or "retro").
func fileIconURL(theme, name string) string {
if name == "" {
return ""
}
return "/static/file-icons/" + theme + "/" + name
}

View File

@@ -0,0 +1,54 @@
package handlers
import (
"path/filepath"
"testing"
)
func TestFileIconMapLoadsAndResolvesCommonTypes(t *testing.T) {
icons, err := loadFileIcons(filepath.Join("..", "..", "static"))
if err != nil {
t.Fatalf("loadFileIcons returned error: %v", err)
}
tests := []struct {
name string
contentType string
wantStandard string
wantRetro string
}{
{
name: "photo.jpg",
contentType: "application/octet-stream",
wantStandard: "image-document-svgrepo-com.svg",
wantRetro: "shimgvw.dll_14_1-2.png",
},
{
name: "movie.mkv",
contentType: "",
wantStandard: "video-document-svgrepo-com.svg",
wantRetro: "wmploc.dll_14_504-2.png",
},
{
name: "archive.7z",
contentType: "",
wantStandard: "zip-document-svgrepo-com.svg",
wantRetro: "zipfldr.dll_14_101-2.png",
},
{
name: "unknown.bin",
contentType: "application/octet-stream",
wantStandard: "txt-document-svgrepo-com.svg",
wantRetro: "shell32.dll_14_152-2.png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := icons.lookup(tt.name, tt.contentType)
if got.Standard != tt.wantStandard || got.Retro != tt.wantRetro {
t.Fatalf("lookup returned %+v, want standard=%q retro=%q", got, tt.wantStandard, tt.wantRetro)
}
})
}
}

View File

@@ -0,0 +1,29 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/middleware"
)
func requestLogAttrs(r *http.Request) []any {
attrs := []any{
"ip", uploadClientIP(r),
"method", r.Method,
"path", r.URL.Path,
}
if requestID := middleware.RequestIDFromContext(r.Context()); requestID != "" {
attrs = append(attrs, "request_id", requestID)
}
if userAgent := r.UserAgent(); userAgent != "" {
attrs = append(attrs, "user_agent", userAgent)
}
return attrs
}
func withRequestLogAttrs(r *http.Request, attrs ...any) []any {
out := make([]any, 0, len(attrs)+8)
out = append(out, attrs...)
out = append(out, requestLogAttrs(r)...)
return out
}

View File

@@ -31,6 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
Description: "Delete this anonymous Warpbox upload.", Description: "Delete this anonymous Warpbox upload.",
Data: a.managePageData(box, r.PathValue("token")), Data: a.managePageData(box, r.PathValue("token")),
}) })
a.logger.Info("anonymous manage page viewed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID)...)
} }
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) { func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
@@ -40,10 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
} }
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil { if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error()) a.logger.Warn("anonymous delete failed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())...)
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
a.logger.Info("anonymous box deleted", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID)...)
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther) http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
} }
@@ -58,10 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) { func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID")) box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil { if err != nil {
a.logger.Warn("anonymous manage missing box", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"))...)
http.NotFound(w, r) http.NotFound(w, r)
return services.Box{}, false return services.Box{}, false
} }
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) { if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
a.logger.Warn("anonymous manage invalid token", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID)...)
http.NotFound(w, r) http.NotFound(w, r)
return services.Box{}, false return services.Box{}, false
} }
@@ -78,7 +82,7 @@ func (a *App) managePageData(box services.Box, token string) managePageData {
Token: token, Token: token,
FileCount: len(box.Files), FileCount: len(box.Files),
TotalSize: helpers.FormatBytes(totalSize), TotalSize: helpers.FormatBytes(totalSize),
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"), ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
DownloadCount: box.DownloadCount, DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads, MaxDownloads: box.MaxDownloads,
Protected: a.uploadService.IsProtected(box), Protected: a.uploadService.IsProtected(box),

View File

@@ -46,6 +46,17 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to load upload policy", http.StatusInternalServerError) http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
return return
} }
actor := "anonymous"
if loggedIn {
actor = "user"
}
a.logger.Info("upload page viewed", withRequestLogAttrs(r,
"source", "page",
"severity", "user_activity",
"code", 2500,
"actor", actor,
"user_id", user.ID,
)...)
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin) maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin) expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
@@ -75,6 +86,10 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser
unlimited = true unlimited = true
case loggedIn: case loggedIn:
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
// A negative per-user MaxDays override means unlimited retention.
if maxDays < 0 {
unlimited = true
}
} }
return buildExpiryOptions(maxDays, unlimited) return buildExpiryOptions(maxDays, unlimited)
} }
@@ -103,6 +118,10 @@ func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
if len(options) == 0 { if len(options) == 0 {
options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)}) options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)})
} }
// Unlimited uploaders can pick "never expires" (sentinel -1) after the ladder.
if unlimited {
options = append(options, expiryOption{Minutes: -1, Label: "Unlimited (never expires)"})
}
// Default to 24h when available, otherwise the smallest option offered. // Default to 24h when available, otherwise the smallest option offered.
defaultMinutes := options[0].Minutes defaultMinutes := options[0].Minutes
@@ -154,5 +173,9 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if policy.StorageQuotaSet { if policy.StorageQuotaSet {
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB) quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
} }
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max." expiryLimit := strconv.Itoa(policy.MaxDays) + " day max."
if policy.MaxDays < 0 {
expiryLimit = "no expiry limit."
}
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
} }

View File

@@ -6,6 +6,8 @@ import (
"net/http" "net/http"
"sync" "sync"
"time" "time"
"warpbox.dev/backend/libs/services"
) )
const csrfCookieName = "warpbox_csrf" const csrfCookieName = "warpbox_csrf"
@@ -76,3 +78,29 @@ func randomToken(byteCount int) string {
} }
return base64.RawURLEncoding.EncodeToString(data) return base64.RawURLEncoding.EncodeToString(data)
} }
func (a *App) recordLoginAbuse(r *http.Request, kind, detail string) {
if a.banService == nil {
return
}
settings, err := a.banService.Settings()
if err != nil || !settings.AutoBanEnabled {
return
}
threshold := settings.UserLoginFailureThreshold
if kind == services.AbuseKindAdminLogin {
threshold = settings.AdminLoginFailureThreshold
}
ip := uploadClientIP(r)
result, err := a.banService.RecordAbuse(ip, kind, detail, threshold, time.Now().UTC())
if err != nil {
a.logger.Error("login abuse event failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "kind", kind, "error", err.Error())
return
}
if result.Enabled {
a.logger.Warn("login abuse recorded", "source", "ban", "severity", "warn", "code", 4304, "ip", ip, "kind", kind, "count", result.Event.Count)
}
if result.Triggered {
a.logger.Warn("ip auto-banned for login abuse", "source", "ban", "severity", "warn", "code", 4305, "ip", ip, "kind", kind, "ban_id", result.Ban.ID)
}
}

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
@@ -15,6 +16,24 @@ func (a *App) Static() http.Handler {
}) })
} }
func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
pack := strings.TrimSpace(r.PathValue("pack"))
file := strings.TrimSpace(r.PathValue("file"))
if pack == "" || file == "" || strings.Contains(pack, "/") || strings.Contains(pack, "\\") || strings.Contains(pack, "..") || strings.Contains(file, "/") || strings.Contains(file, "\\") || strings.Contains(file, "..") || !isEmojiFile(file) {
http.NotFound(w, r)
return
}
path := filepath.Join(a.emojiRoot(), pack, file)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
setStaticCacheHeaders(w, r.URL.Path)
http.ServeFile(w, r, path)
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) { func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))

View File

@@ -18,6 +18,7 @@ import (
func (a *App) Upload(w http.ResponseWriter, r *http.Request) { func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
user, loggedIn, authErr := a.currentUserWithAuthError(r) user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil { if authErr != nil {
a.logger.Warn("upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4010)...)
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token") helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return return
} }
@@ -29,12 +30,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return return
} }
if !loggedIn && !settings.AnonymousUploadsEnabled { if !loggedIn && !settings.AnonymousUploadsEnabled {
a.logger.Warn("anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4012)...)
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled") helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return return
} }
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn) effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn) rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) { if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
a.logger.Warn("upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4290, "user_id", user.ID)...)
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down") helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return return
} }
@@ -49,6 +52,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
parseLimit = 32 << 20 parseLimit = 32 << 20
} }
if err := r.ParseMultipartForm(parseLimit); err != nil { if err := r.ParseMultipartForm(parseLimit); err != nil {
a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read") helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return return
} }
@@ -61,30 +65,51 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
ownerID = user.ID ownerID = user.ID
collectionID = r.FormValue("collection_id") collectionID = r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) { if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
a.logger.Warn("upload rejected invalid collection", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)...)
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found") helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
return return
} }
} }
if !isAdminUpload { if !isAdminUpload {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" { if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))...)
helpers.WriteJSONError(w, status, message) helpers.WriteJSONError(w, status, message)
return return
} }
} }
maxDays := parseInt(r.FormValue("max_days")) // Unlimited expiry: admins, or users whose effective MaxDays is negative.
unlimitedExpiry := isAdminUpload || effectivePolicy.MaxDays < 0
rawMaxDays := parseInt(r.FormValue("max_days"))
maxDays := rawMaxDays
if maxDays <= 0 { if maxDays <= 0 {
maxDays = min(7, effectivePolicy.MaxDays) maxDays = 7
if effectivePolicy.MaxDays > 0 && effectivePolicy.MaxDays < maxDays {
maxDays = effectivePolicy.MaxDays
} }
if !isAdminUpload && maxDays > effectivePolicy.MaxDays { }
if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
a.logger.Warn("upload rejected expiration days", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4131, "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)...)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return return
} }
expiresMinutes := parseInt(r.FormValue("expires_minutes")) expiresMinutes := parseInt(r.FormValue("expires_minutes"))
if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 { // A negative expires_minutes (or max_days) is the "never expires" request.
// Only honour it for unlimited uploaders; otherwise it's an invalid value.
if expiresMinutes < 0 || rawMaxDays < 0 {
if !unlimitedExpiry {
a.logger.Warn("upload rejected unlimited expiration", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4133, "user_id", user.ID)...)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return return
} }
result, err := a.uploadService.CreateBox(files, services.UploadOptions{ expiresMinutes = -1
} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
a.logger.Warn("upload rejected expiration minutes", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4132, "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)...)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return
}
opts := services.UploadOptions{
MaxDays: maxDays, MaxDays: maxDays,
ExpiresInMinutes: expiresMinutes, ExpiresInMinutes: expiresMinutes,
MaxDownloads: parseInt(r.FormValue("max_downloads")), MaxDownloads: parseInt(r.FormValue("max_downloads")),
@@ -95,14 +120,20 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0, SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
CreatorIP: uploadClientIP(r), CreatorIP: uploadClientIP(r),
StorageBackendID: effectivePolicy.StorageBackendID, StorageBackendID: effectivePolicy.StorageBackendID,
}) }
result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
if policyMessage != "" {
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))...)
helpers.WriteJSONError(w, status, policyMessage)
return
}
if err != nil { if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error()) a.logger.Warn("upload failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4001, "user_id", user.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error()) helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return return
} }
if !isAdminUpload { if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil { if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, boxesAdded); err != nil {
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error()) a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
} }
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil { if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
@@ -110,6 +141,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
} }
} }
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID) jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
a.logger.Info("box uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2001, "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
if wantsJSON(r) { if wantsJSON(r) {
helpers.WriteJSON(w, http.StatusCreated, result) helpers.WriteJSON(w, http.StatusCreated, result)
@@ -121,6 +153,77 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL) _, _ = fmt.Fprintln(w, result.BoxURL)
} }
// createOrAppendBox creates a new box. It only ever appends to an existing box
// when the request opts in via the X-Warpbox-Batch header: requests sharing the
// same batch value (per account, or per IP for anonymous) within
// uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
if batch == "" {
if enforceBoxLimits {
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
return result, 1, 0, "", nil
}
// Group key is scoped to the uploader so batches never cross accounts/IPs.
identity := "ip:" + uploadClientIP(r)
if loggedIn {
identity = "user:" + user.ID
}
entry := a.uploadGroups.entryFor(identity + "|" + batch)
// Hold the per-key lock across the whole create/append so concurrent batched
// uploads serialise into the same box instead of racing.
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
// Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL.
result.ManageURL = entry.manageURL
result.DeleteURL = entry.deleteURL
entry.at = time.Now()
return result, 0, 0, "", nil
}
}
}
if enforceBoxLimits {
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
entry.boxID = result.BoxID
entry.manageURL = result.ManageURL
entry.deleteURL = result.DeleteURL
entry.at = time.Now()
return result, 1, 0, "", nil
}
// batchBoxMatches guards that a batched append only ever touches a box owned by
// the same uploader (account for logged-in users, creator IP for anonymous).
func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool {
if loggedIn {
return box.OwnerID == user.ID
}
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) { func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
if len(files) == 0 { if len(files) == 0 {
return 0, "" return 0, ""
@@ -142,16 +245,6 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "anonymous daily upload limit reached" 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 != "" { if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message return status, message
} }
@@ -165,16 +258,6 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "daily upload limit reached" return http.StatusTooManyRequests, "daily upload limit reached"
} }
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID) activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil { if err != nil {
return http.StatusInternalServerError, "storage quota could not be checked" return http.StatusInternalServerError, "storage quota could not be checked"
@@ -188,6 +271,42 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
return 0, "" return 0, ""
} }
func (a *App) checkBoxCreationPolicy(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy) (int, string) {
now := time.Now().UTC()
if !loggedIn {
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "anonymous daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "anonymous active box limit reached"
}
return 0, ""
}
usage, err := a.settingsService.UsageForUser(user.ID, now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
return 0, ""
}
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error { func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
now := time.Now().UTC() now := time.Now().UTC()
if loggedIn { if loggedIn {
@@ -235,7 +354,10 @@ func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fall
} }
func uploadClientIP(r *http.Request) string { func uploadClientIP(r *http.Request) string {
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) if ip, ok := services.ClientIPFromContext(r); ok {
return ip
}
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
} }
func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string { func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {

View File

@@ -0,0 +1,76 @@
package handlers
import (
"sync"
"time"
)
// uploadGroupWindow is how long after a batched upload a follow-up upload with
// the same X-Warpbox-Batch value (and same account/IP) is folded into the same
// box. ShareX sends a multi-file selection as separate back-to-back requests;
// the batch header lets it land them in one box.
const uploadGroupWindow = 20 * time.Second
// uploadBatchHeader is the opt-in request header. Without it, uploads behave
// exactly as before (one box per request). With it, requests sharing the same
// value (per account/IP) within uploadGroupWindow are grouped into one box.
const uploadBatchHeader = "X-Warpbox-Batch"
// uploadGroupPruneInterval is how often entryFor drops stale entries so the map
// can't grow without bound (one key per account/IP + batch value otherwise).
const uploadGroupPruneInterval = 5 * time.Minute
// uploadGrouper tracks the most recent box per batch key so opt-in batched
// uploads land in a single box. Each key has its own lock, which also serialises
// that key's concurrent uploads so they append to the same box instead of racing
// to create several.
type uploadGrouper struct {
mu sync.Mutex
entries map[string]*uploadGroupEntry
lastPrune time.Time
}
type uploadGroupEntry struct {
mu sync.Mutex
boxID string
manageURL string
deleteURL string
at time.Time
}
func newUploadGrouper() *uploadGrouper {
return &uploadGrouper{entries: make(map[string]*uploadGroupEntry)}
}
func (g *uploadGrouper) entryFor(key string) *uploadGroupEntry {
g.mu.Lock()
defer g.mu.Unlock()
g.pruneLocked(time.Now())
entry, ok := g.entries[key]
if !ok {
entry = &uploadGroupEntry{at: time.Now()}
g.entries[key] = entry
}
return entry
}
// pruneLocked drops entries whose last use is well past the grouping window so
// the map stays bounded to recently-active keys. Callers must hold g.mu. Entries
// currently in use are kept to avoid removing one a request is about to
// populate.
func (g *uploadGrouper) pruneLocked(now time.Time) {
if now.Sub(g.lastPrune) < uploadGroupPruneInterval {
return
}
g.lastPrune = now
for key, entry := range g.entries {
if !entry.mu.TryLock() {
continue
}
stale := now.Sub(entry.at) > 2*uploadGroupWindow
entry.mu.Unlock()
if stale {
delete(g.entries, key)
}
}
}

View File

@@ -0,0 +1,24 @@
package handlers
import (
"testing"
"time"
)
func TestUploadGroupPrunesFailedEntries(t *testing.T) {
g := newUploadGrouper()
entry := g.entryFor("ip:203.0.113.1|failed")
entry.mu.Lock()
entry.at = time.Now().Add(-3 * uploadGroupWindow)
entry.mu.Unlock()
g.lastPrune = time.Now().Add(-uploadGroupPruneInterval)
_ = g.entryFor("ip:203.0.113.1|next")
if _, ok := g.entries["ip:203.0.113.1|failed"]; ok {
t.Fatalf("stale failed entry was not pruned")
}
if _, ok := g.entries["ip:203.0.113.1|next"]; !ok {
t.Fatalf("new entry was not created")
}
}

View File

@@ -46,6 +46,42 @@ func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) {
} }
} }
func TestFileReactionCanBeAddedOncePerVisitor(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
if len(payload.Files) != 1 {
t.Fatalf("uploaded files = %d", len(payload.Files))
}
request := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.ReactToFile(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("first reaction status = %d, body = %s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), `"count":1`) {
t.Fatalf("reaction response missing count: %s", response.Body.String())
}
retry := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
retry.Header.Set("Content-Type", "application/x-www-form-urlencoded")
retry.SetPathValue("boxID", payload.BoxID)
retry.SetPathValue("fileID", payload.Files[0].ID)
for _, cookie := range response.Result().Cookies() {
retry.AddCookie(cookie)
}
retryResponse := httptest.NewRecorder()
app.ReactToFile(retryResponse, retry)
if retryResponse.Code != http.StatusConflict {
t.Fatalf("second reaction status = %d, body = %s", retryResponse.Code, retryResponse.Body.String())
}
}
func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) { func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -179,6 +215,7 @@ func newTestApp(t *testing.T) (*App, func()) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil)) logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{ cfg := config.Config{
AppName: "warpbox.dev", AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test", BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"), DataDir: filepath.Join(root, "data"),
StaticDir: staticDir, StaticDir: staticDir,
@@ -197,7 +234,15 @@ func newTestApp(t *testing.T) (*App, func()) {
if err != nil { if err != nil {
t.Fatalf("NewUploadService returned error: %v", err) t.Fatalf("NewUploadService returned error: %v", err)
} }
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL) if err := os.MkdirAll(filepath.Join(cfg.DataDir, "emoji", "openmoji"), 0o755); err != nil {
service.Close()
t.Fatalf("create emoji test dir: %v", err)
}
if err := os.WriteFile(filepath.Join(cfg.DataDir, "emoji", "openmoji", "1F600.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>`), 0o644); err != nil {
service.Close()
t.Fatalf("write emoji test file: %v", err)
}
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil { if err != nil {
service.Close() service.Close()
t.Fatalf("NewRenderer returned error: %v", err) t.Fatalf("NewRenderer returned error: %v", err)
@@ -212,7 +257,17 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close() service.Close()
t.Fatalf("NewSettingsService returned error: %v", err) t.Fatalf("NewSettingsService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() { reactionService, err := services.NewReactionService(service.DB())
if err != nil {
service.Close()
t.Fatalf("NewReactionService returned error: %v", err)
}
banService, err := services.NewBanService(service.DB())
if err != nil {
service.Close()
t.Fatalf("NewBanService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }

View File

@@ -13,7 +13,7 @@ import (
) )
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL) renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -32,8 +32,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
uploadService.Close() uploadService.Close()
return nil, err return nil, err
} }
stopJobs := jobs.StartAll(cfg, logger, uploadService) reactionService, err := services.NewReactionService(uploadService.DB())
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService) if err != nil {
uploadService.Close()
return nil, err
}
banService, err := services.NewBanService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, reactionService, banService)
router := http.NewServeMux() router := http.NewServeMux()
app.RegisterRoutes(router) app.RegisterRoutes(router)
@@ -44,12 +54,14 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
middleware.RequestID, middleware.RequestID,
middleware.SecurityHeaders, middleware.SecurityHeaders,
middleware.Gzip, middleware.Gzip,
middleware.Logger(logger), middleware.ClientIP(cfg.TrustedProxies),
middleware.Bans(logger, banService, cfg.TrustedProxies),
) )
server := &http.Server{ server := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: handler, Handler: handler,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout, ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout, WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout, IdleTimeout: cfg.IdleTimeout,

View File

@@ -8,7 +8,7 @@ import (
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
) )
func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job { func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) job {
return job{ return job{
name: "cleanup", name: "cleanup",
enabled: cfg.CleanupEnabled, enabled: cfg.CleanupEnabled,
@@ -22,6 +22,16 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
if cleaned > 0 { if cleaned > 0 {
logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned) logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
} }
if banService != nil {
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
if err != nil {
logger.Warn("ban evidence cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4203, "error", err.Error())
return
}
if cleanedEvents > 0 {
logger.Info("ban evidence cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2203, "cleaned", cleanedEvents)
}
}
}, },
} }
} }

View File

@@ -16,14 +16,14 @@ type job struct {
run func() run func()
} }
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) func() { func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) func() {
if !cfg.JobsEnabled { if !cfg.JobsEnabled {
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev") logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
return func() {} return func() {}
} }
stops := []func(){ stops := []func(){
start(newCleanupJob(cfg, logger, uploadService), logger), start(newCleanupJob(cfg, logger, uploadService, banService), logger),
start(newThumbnailsJob(cfg, logger, uploadService), logger), start(newThumbnailsJob(cfg, logger, uploadService), logger),
} }

View File

@@ -0,0 +1,64 @@
package middleware
import (
"log/slog"
"net/http"
"time"
"warpbox.dev/backend/libs/services"
)
func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, ok := services.ClientIPFromContext(r)
if !ok {
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
r = services.WithClientIP(r, ip)
}
now := time.Now().UTC()
protectedProxy := services.IsProtectedProxyIP(ip, trustedProxies)
if bans != nil && !protectedProxy {
if matched, ok, err := bans.Match(ip, now); err != nil {
logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error())
} else if ok {
logger.Warn("banned request blocked", "source", "ban", "severity", "warn", "code", 4030, "ip", ip, "ban_id", matched.Ban.ID, "target", matched.Ban.Normalized, "path", r.URL.Path)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("forbidden\n"))
return
}
settings, err := bans.Settings()
if err != nil {
logger.Error("ban settings load failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "error", err.Error())
next.ServeHTTP(w, r)
return
}
if !settings.AutoBanEnabled {
next.ServeHTTP(w, r)
return
}
if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil {
logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error())
} else if pattern != "" {
if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, settings.MaliciousPathThreshold, now); err != nil {
logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error())
} else if result.Enabled {
logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count)
if result.Triggered {
logger.Warn("ip auto-banned for malicious path", "source", "ban", "severity", "warn", "code", 4303, "ip", ip, "ban_id", result.Ban.ID, "path", r.URL.Path)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("forbidden\n"))
return
}
}
}
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,168 @@
package middleware
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"warpbox.dev/backend/libs/services"
)
func TestBansMiddlewareBlocksActiveBan(t *testing.T) {
bans := newMiddlewareBanService(t)
if _, err := bans.CreateManualBan("203.0.113.20", "test", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
t.Fatalf("CreateManualBan returned error: %v", err)
}
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next handler should not be called")
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.RemoteAddr = "127.0.0.1:6070"
request.Header.Set("X-Forwarded-For", "203.0.113.20")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || response.Body.String() != "forbidden\n" {
t.Fatalf("blocked response = %d %q", response.Code, response.Body.String())
}
}
func TestBansMiddlewareAllowsNonBannedIP(t *testing.T) {
bans := newMiddlewareBanService(t)
called := false
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = io.WriteString(w, "ok")
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.RemoteAddr = "203.0.113.21:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if !called || response.Code != http.StatusOK {
t.Fatalf("allowed response = called %v code %d", called, response.Code)
}
}
func TestBansMiddlewareAutoBansMaliciousPaths(t *testing.T) {
bans := newMiddlewareBanService(t)
settings, err := bans.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.MaliciousPathThreshold = 3
if err := bans.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
for i := 0; i < 3; i++ {
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
request.RemoteAddr = "203.0.113.22:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if i < 2 && response.Code == http.StatusForbidden {
t.Fatalf("request %d blocked before threshold", i+1)
}
if i == 2 && response.Code != http.StatusForbidden {
t.Fatalf("request 3 status = %d, want forbidden", response.Code)
}
}
}
func TestBansMiddlewareSkipsAutoBanWhenDisabled(t *testing.T) {
bans := newMiddlewareBanService(t)
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
for i := 0; i < 5; i++ {
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
request.RemoteAddr = "203.0.113.23:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code == http.StatusForbidden {
t.Fatalf("request %d was blocked while auto-ban disabled", i+1)
}
}
if _, ok, err := bans.Match("203.0.113.23", time.Now().UTC()); err != nil || ok {
t.Fatalf("disabled auto-ban Match = %v, %v", ok, err)
}
}
func TestBansMiddlewareDoesNotBlockProtectedProxyIP(t *testing.T) {
bans := newMiddlewareBanService(t)
if _, err := bans.CreateManualBan("127.0.0.1", "bad historical ban", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
t.Fatalf("CreateManualBan returned error: %v", err)
}
called := false
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = io.WriteString(w, "ok")
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
request := httptest.NewRequest(http.MethodGet, "/", nil)
request.RemoteAddr = "127.0.0.1:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if !called || response.Code != http.StatusOK {
t.Fatalf("protected proxy response = called %v code %d", called, response.Code)
}
}
func TestBansMiddlewareDoesNotAutoBanProtectedProxyIP(t *testing.T) {
bans := newMiddlewareBanService(t)
settings, err := bans.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.MaliciousPathThreshold = 1
if err := bans.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
request.RemoteAddr = "127.0.0.1:6070"
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code == http.StatusForbidden {
t.Fatalf("protected proxy was auto-banned")
}
if _, ok, err := bans.Match("127.0.0.1", time.Now().UTC()); err != nil || ok {
t.Fatalf("protected proxy Match = %v, %v", ok, err)
}
}
func newMiddlewareBanService(t *testing.T) *services.BanService {
t.Helper()
root := t.TempDir()
upload, err := services.NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := upload.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
bans, err := services.NewBanService(upload.DB())
if err != nil {
t.Fatalf("NewBanService returned error: %v", err)
}
return bans
}

View File

@@ -0,0 +1,16 @@
package middleware
import (
"net/http"
"warpbox.dev/backend/libs/services"
)
func ClientIP(trustedProxies []string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
next.ServeHTTP(w, services.WithClientIP(r, ip))
})
}
}

View File

@@ -1,57 +0,0 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(data []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(data)
r.bytes += n
return n, err
}
func Logger(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(recorder, r)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
logger.Info("http request",
"source", "http",
"severity", "dev",
"code", status,
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", recorder.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
})
}
}

View File

@@ -574,6 +574,38 @@ func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
return s.saveUser(user) return s.saveUser(user)
} }
func (s *AuthService) ClearStorageBackendOverrides(backendID string) (int, error) {
backendID = strings.TrimSpace(backendID)
if backendID == "" {
return 0, nil
}
cleared := 0
err := s.db.Update(func(tx *bbolt.Tx) error {
users := tx.Bucket(usersBucket)
return users.ForEach(func(key, value []byte) error {
var user User
if err := json.Unmarshal(value, &user); err != nil {
return err
}
if user.Policy.StorageBackendID == nil || *user.Policy.StorageBackendID != backendID {
return nil
}
user.Policy.StorageBackendID = nil
user.UpdatedAt = time.Now().UTC()
next, err := json.Marshal(user)
if err != nil {
return err
}
if err := users.Put(key, next); err != nil {
return err
}
cleared++
return nil
})
})
return cleared, err
}
func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) { func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) {
if err := validateUserPolicy(policy); err != nil { if err := validateUserPolicy(policy); err != nil {
return User{}, err return User{}, err
@@ -862,20 +894,20 @@ func validateUserPolicy(policy UserPolicy) error {
if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) { 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") return fmt.Errorf("daily upload override must be positive or -1 for unlimited")
} }
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 { if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 && *policy.StorageQuotaMB != -1 {
return fmt.Errorf("storage quota override cannot be negative") return fmt.Errorf("storage quota override must be 0/positive or -1 for unlimited")
} }
if policy.MaxDays != nil && *policy.MaxDays <= 0 { if policy.MaxDays != nil && *policy.MaxDays <= 0 && *policy.MaxDays != -1 {
return fmt.Errorf("expiration override must be positive") return fmt.Errorf("expiration override must be positive or -1 for unlimited")
} }
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 { if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 && *policy.DailyBoxes != -1 {
return fmt.Errorf("daily box override must be positive") return fmt.Errorf("daily box override must be positive or -1 for unlimited")
} }
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 { if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 && *policy.ActiveBoxes != -1 {
return fmt.Errorf("active box override must be positive") return fmt.Errorf("active box override must be positive or -1 for unlimited")
} }
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 { if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 {
return fmt.Errorf("short-window request override must be positive") return fmt.Errorf("short-window request override must be positive or -1 for unlimited")
} }
return nil return nil
} }

View File

@@ -0,0 +1,571 @@
package services
import (
"encoding/json"
"errors"
"fmt"
"net"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var (
bansBucket = []byte("bans")
abuseEventsBucket = []byte("abuse_events")
banRulesBucket = []byte("ban_rules")
banSettingsBucket = []byte("ban_settings")
banSettingsKey = []byte("settings")
defaultBanRulesSeed = []byte("default_rules_seeded")
)
const (
BanSourceManual = "manual"
BanSourceAuto = "auto"
AbuseKindMaliciousPath = "malicious_path"
AbuseKindAdminLogin = "admin_login_failure"
AbuseKindUserLogin = "user_login_failure"
)
var defaultMaliciousPathRules = []string{
"/wp-admin",
"/.env",
"/.git/config",
"/phpmyadmin",
"/wp-login.php",
"/xmlrpc.php",
"/config.php",
"/vendor/phpunit",
".env",
"backup",
"dump.sql",
}
var ErrBanNotFound = errors.New("ban not found")
type BanService struct {
db *bbolt.DB
}
type BanSettings struct {
AutoBanEnabled bool `json:"autoBanEnabled"`
AutoBanDurationHours int `json:"autoBanDurationHours"`
MaliciousPathThreshold int `json:"maliciousPathThreshold"`
AdminLoginFailureThreshold int `json:"adminLoginFailureThreshold"`
UserLoginFailureThreshold int `json:"userLoginFailureThreshold"`
AbuseWindowHours int `json:"abuseWindowHours"`
}
type BanRecord struct {
ID string `json:"id"`
Target string `json:"target"`
Normalized string `json:"normalized"`
Reason string `json:"reason"`
Source string `json:"source"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ExpiresAt time.Time `json:"expiresAt"`
UnbannedAt *time.Time `json:"unbannedAt,omitempty"`
LastMatchedAt *time.Time `json:"lastMatchedAt,omitempty"`
}
type BanRule struct {
ID string `json:"id"`
Pattern string `json:"pattern"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type AbuseEvent struct {
Key string `json:"key"`
IP string `json:"ip"`
Kind string `json:"kind"`
Count int `json:"count"`
FirstSeen time.Time `json:"firstSeen"`
LastSeen time.Time `json:"lastSeen"`
Detail string `json:"detail,omitempty"`
}
type MatchedBan struct {
Ban BanRecord
IP string
}
type AbuseResult struct {
Event AbuseEvent
Ban BanRecord
Triggered bool
Enabled bool
}
func NewBanService(db *bbolt.DB) (*BanService, error) {
service := &BanService{db: db}
err := db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{bansBucket, abuseEventsBucket, banRulesBucket, banSettingsBucket} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
if tx.Bucket(banSettingsBucket).Get(banSettingsKey) == nil {
data, err := json.Marshal(DefaultBanSettings())
if err != nil {
return err
}
if err := tx.Bucket(banSettingsBucket).Put(banSettingsKey, data); err != nil {
return err
}
}
rules := tx.Bucket(banRulesBucket)
if rules.Get(defaultBanRulesSeed) == nil {
now := time.Now().UTC()
for _, pattern := range defaultMaliciousPathRules {
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
data, err := json.Marshal(rule)
if err != nil {
return err
}
if err := rules.Put([]byte(rule.ID), data); err != nil {
return err
}
}
if err := rules.Put(defaultBanRulesSeed, []byte("1")); err != nil {
return err
}
}
return nil
})
return service, err
}
func DefaultBanSettings() BanSettings {
return BanSettings{
AutoBanEnabled: false,
AutoBanDurationHours: 24,
MaliciousPathThreshold: 3,
AdminLoginFailureThreshold: 10,
UserLoginFailureThreshold: 30,
AbuseWindowHours: 24,
}
}
func (s *BanService) Settings() (BanSettings, error) {
settings := DefaultBanSettings()
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(banSettingsBucket).Get(banSettingsKey)
if data == nil {
return nil
}
if err := json.Unmarshal(data, &settings); err != nil {
return err
}
settings = withBanSettingDefaults(settings)
return nil
})
if err != nil {
return BanSettings{}, err
}
return settings, nil
}
func (s *BanService) UpdateSettings(settings BanSettings) error {
settings = withBanSettingDefaults(settings)
if settings.AutoBanDurationHours <= 0 || settings.MaliciousPathThreshold <= 0 ||
settings.AdminLoginFailureThreshold <= 0 || settings.UserLoginFailureThreshold <= 0 ||
settings.AbuseWindowHours <= 0 {
return fmt.Errorf("ban settings must be positive")
}
data, err := json.Marshal(settings)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(banSettingsBucket).Put(banSettingsKey, data)
})
}
func withBanSettingDefaults(settings BanSettings) BanSettings {
defaults := DefaultBanSettings()
if settings.AutoBanDurationHours <= 0 {
settings.AutoBanDurationHours = defaults.AutoBanDurationHours
}
if settings.MaliciousPathThreshold <= 0 {
settings.MaliciousPathThreshold = defaults.MaliciousPathThreshold
}
if settings.AdminLoginFailureThreshold <= 0 {
settings.AdminLoginFailureThreshold = defaults.AdminLoginFailureThreshold
}
if settings.UserLoginFailureThreshold <= 0 {
settings.UserLoginFailureThreshold = defaults.UserLoginFailureThreshold
}
if settings.AbuseWindowHours <= 0 {
settings.AbuseWindowHours = defaults.AbuseWindowHours
}
return settings
}
func (s *BanService) CreateManualBan(target, reason, createdBy string, expiresAt time.Time) (BanRecord, error) {
return s.createBan(target, reason, BanSourceManual, createdBy, expiresAt, time.Now().UTC())
}
func (s *BanService) createBan(target, reason, source, createdBy string, expiresAt, now time.Time) (BanRecord, error) {
normalized, err := NormalizeBanTarget(target)
if err != nil {
return BanRecord{}, err
}
reason = strings.TrimSpace(reason)
if reason == "" {
return BanRecord{}, fmt.Errorf("ban reason is required")
}
if !expiresAt.After(now) {
return BanRecord{}, fmt.Errorf("ban expiration must be in the future")
}
record := BanRecord{
ID: randomID(12),
Target: strings.TrimSpace(target),
Normalized: normalized,
Reason: reason,
Source: source,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: expiresAt.UTC(),
}
data, err := json.Marshal(record)
if err != nil {
return BanRecord{}, err
}
err = s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bansBucket).Put([]byte(record.ID), data)
})
return record, err
}
func NormalizeBanTarget(target string) (string, error) {
target = strings.TrimSpace(target)
if target == "" {
return "", fmt.Errorf("ban target is required")
}
if strings.Contains(target, "/") {
_, network, err := net.ParseCIDR(target)
if err != nil {
return "", fmt.Errorf("invalid CIDR target")
}
return network.String(), nil
}
ip := net.ParseIP(target)
if ip == nil {
return "", fmt.Errorf("invalid IP target")
}
return ip.String(), nil
}
func (s *BanService) ListBans() ([]BanRecord, error) {
records := []BanRecord{}
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(bansBucket).ForEach(func(_, value []byte) error {
var record BanRecord
if err := json.Unmarshal(value, &record); err != nil {
return err
}
records = append(records, record)
return nil
})
})
sort.Slice(records, func(i, j int) bool {
return records[i].CreatedAt.After(records[j].CreatedAt)
})
return records, err
}
func (s *BanService) Unban(id string, now time.Time) error {
id = strings.TrimSpace(id)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bansBucket)
data := bucket.Get([]byte(id))
if data == nil {
return ErrBanNotFound
}
var record BanRecord
if err := json.Unmarshal(data, &record); err != nil {
return err
}
now = now.UTC()
record.UnbannedAt = &now
record.UpdatedAt = now
next, err := json.Marshal(record)
if err != nil {
return err
}
return bucket.Put([]byte(id), next)
})
}
func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
parsed := net.ParseIP(strings.TrimSpace(ip))
if parsed == nil {
return MatchedBan{}, false, nil
}
now = now.UTC()
var matched BanRecord
var matchedKey []byte
// Read-only scan first: the common case (no match) only takes a concurrent
// read transaction, instead of grabbing the single bbolt write lock on every
// request that flows through the ban middleware.
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bansBucket)
return bucket.ForEach(func(key, value []byte) error {
if matched.ID != "" {
return nil
}
var record BanRecord
if err := json.Unmarshal(value, &record); err != nil {
return err
}
if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) {
return nil
}
matched = record
matchedKey = append([]byte(nil), key...) // key bytes are only valid within the txn
return nil
})
})
if err != nil || matched.ID == "" {
return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err
}
// On a hit, record the match time in a short write transaction.
matched.LastMatchedAt = &now
matched.UpdatedAt = now
_ = s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bansBucket)
data := bucket.Get(matchedKey)
if data == nil {
return nil
}
var record BanRecord
if err := json.Unmarshal(data, &record); err != nil {
return nil
}
record.LastMatchedAt = &now
record.UpdatedAt = now
next, err := json.Marshal(record)
if err != nil {
return nil
}
return bucket.Put(matchedKey, next)
})
return MatchedBan{Ban: matched, IP: ip}, true, nil
}
func (r BanRecord) Active(now time.Time) bool {
return r.UnbannedAt == nil && r.ExpiresAt.After(now.UTC())
}
func (r BanRecord) Status(now time.Time) string {
switch {
case r.UnbannedAt != nil:
return "unbanned"
case !r.ExpiresAt.After(now.UTC()):
return "expired"
default:
return "active"
}
}
func banTargetMatches(target string, ip net.IP) bool {
if strings.Contains(target, "/") {
if _, network, err := net.ParseCIDR(target); err == nil {
return network.Contains(ip)
}
return false
}
targetIP := net.ParseIP(target)
return targetIP != nil && targetIP.Equal(ip)
}
func (s *BanService) ListRules() ([]BanRule, error) {
rules := []BanRule{}
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(banRulesBucket).ForEach(func(key, value []byte) error {
if string(key) == string(defaultBanRulesSeed) {
return nil
}
var rule BanRule
if err := json.Unmarshal(value, &rule); err != nil {
return err
}
rules = append(rules, rule)
return nil
})
})
sort.Slice(rules, func(i, j int) bool {
return strings.ToLower(rules[i].Pattern) < strings.ToLower(rules[j].Pattern)
})
return rules, err
}
func (s *BanService) SaveRules(patterns []string, now time.Time) error {
now = now.UTC()
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(banRulesBucket)
deleteKeys := [][]byte{}
if err := bucket.ForEach(func(key, _ []byte) error {
if string(key) == string(defaultBanRulesSeed) {
return nil
}
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
return nil
}); err != nil {
return err
}
for _, key := range deleteKeys {
if err := bucket.Delete(key); err != nil {
return err
}
}
seen := map[string]bool{}
for _, pattern := range patterns {
pattern = strings.TrimSpace(pattern)
if pattern == "" || seen[strings.ToLower(pattern)] {
continue
}
seen[strings.ToLower(pattern)] = true
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
data, err := json.Marshal(rule)
if err != nil {
return err
}
if err := bucket.Put([]byte(rule.ID), data); err != nil {
return err
}
}
return nil
})
}
func (s *BanService) DeleteRule(id string) error {
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(banRulesBucket).Delete([]byte(strings.TrimSpace(id)))
})
}
func (s *BanService) MaliciousPattern(path string) (string, error) {
if shouldSkipMaliciousPath(path) {
return "", nil
}
rules, err := s.ListRules()
if err != nil {
return "", err
}
lowerPath := strings.ToLower(path)
for _, rule := range rules {
if rule.Enabled && strings.Contains(lowerPath, strings.ToLower(rule.Pattern)) {
return rule.Pattern, nil
}
}
return "", nil
}
func shouldSkipMaliciousPath(path string) bool {
return path == "/health" || strings.HasPrefix(path, "/static/")
}
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {
settings, err := s.Settings()
if err != nil {
return AbuseResult{}, err
}
if !settings.AutoBanEnabled {
return AbuseResult{Enabled: false}, nil
}
if threshold <= 0 {
return AbuseResult{Enabled: true}, nil
}
now = now.UTC()
window := time.Duration(settings.AbuseWindowHours) * time.Hour
key := abuseKey(ip, kind)
var event AbuseEvent
var triggered bool
var ban BanRecord
err = s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(abuseEventsBucket)
data := bucket.Get([]byte(key))
if data != nil {
if err := json.Unmarshal(data, &event); err != nil {
return err
}
}
if data == nil || now.Sub(event.FirstSeen) > window {
event = AbuseEvent{Key: key, IP: ip, Kind: kind, FirstSeen: now}
}
event.Count++
event.LastSeen = now
event.Detail = detail
next, err := json.Marshal(event)
if err != nil {
return err
}
if err := bucket.Put([]byte(key), next); err != nil {
return err
}
triggered = event.Count >= threshold
return nil
})
if err != nil || !triggered {
return AbuseResult{Event: event, Triggered: false, Enabled: true}, err
}
if matched, ok, err := s.Match(ip, now); err != nil {
return AbuseResult{}, err
} else if ok {
return AbuseResult{Event: event, Ban: matched.Ban, Triggered: true, Enabled: true}, nil
}
reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail)
ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now)
if err != nil {
return AbuseResult{}, err
}
return AbuseResult{Event: event, Ban: ban, Triggered: true, Enabled: true}, nil
}
func (s *BanService) CleanupAbuseEvents(now time.Time) (int, error) {
settings, err := s.Settings()
if err != nil {
return 0, err
}
cutoff := now.UTC().Add(-time.Duration(settings.AbuseWindowHours) * time.Hour)
cleaned := 0
err = s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(abuseEventsBucket)
deleteKeys := [][]byte{}
if err := bucket.ForEach(func(key, value []byte) error {
var event AbuseEvent
if err := json.Unmarshal(value, &event); err != nil {
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
return nil
}
if event.LastSeen.Before(cutoff) {
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
}
return nil
}); err != nil {
return err
}
for _, key := range deleteKeys {
if err := bucket.Delete(key); err != nil {
return err
}
cleaned++
}
return nil
})
return cleaned, err
}
func abuseKey(ip, kind string) string {
return kind + ":" + strings.TrimSpace(ip)
}

View File

@@ -0,0 +1,128 @@
package services
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestBanServiceMatchesIPAndCIDR(t *testing.T) {
bans := newTestBanService(t)
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
ipBan, err := bans.createBan("203.0.113.5", "single IP", BanSourceManual, "test", now.Add(time.Hour), now)
if err != nil {
t.Fatalf("createBan IP returned error: %v", err)
}
cidrBan, err := bans.createBan("198.51.100.0/24", "CIDR", BanSourceManual, "test", now.Add(time.Hour), now)
if err != nil {
t.Fatalf("createBan CIDR returned error: %v", err)
}
if matched, ok, err := bans.Match("203.0.113.5", now); err != nil || !ok || matched.Ban.ID != ipBan.ID {
t.Fatalf("Match IP = %+v, %v, %v", matched, ok, err)
}
if matched, ok, err := bans.Match("198.51.100.42", now); err != nil || !ok || matched.Ban.ID != cidrBan.ID {
t.Fatalf("Match CIDR = %+v, %v, %v", matched, ok, err)
}
if _, ok, err := bans.Match("192.0.2.1", now); err != nil || ok {
t.Fatalf("Match unrelated = %v, %v", ok, err)
}
}
func TestBanServiceIgnoresExpiredAndUnbanned(t *testing.T) {
bans := newTestBanService(t)
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
expired, err := bans.createBan("203.0.113.6", "expired", BanSourceManual, "test", now.Add(time.Hour), now)
if err != nil {
t.Fatalf("createBan expired returned error: %v", err)
}
if _, ok, err := bans.Match("203.0.113.6", now.Add(2*time.Hour)); err != nil || ok {
t.Fatalf("expired Match = %v, %v", ok, err)
}
active, err := bans.createBan("203.0.113.7", "active", BanSourceManual, "test", now.Add(time.Hour), now)
if err != nil {
t.Fatalf("createBan active returned error: %v", err)
}
if err := bans.Unban(active.ID, now.Add(time.Minute)); err != nil {
t.Fatalf("Unban returned error: %v", err)
}
if _, ok, err := bans.Match("203.0.113.7", now.Add(2*time.Minute)); err != nil || ok {
t.Fatalf("unbanned Match = %v, %v", ok, err)
}
if expired.Status(now.Add(2*time.Hour)) != "expired" {
t.Fatalf("expired status = %q", expired.Status(now.Add(2*time.Hour)))
}
}
func TestBanServiceAutoBanThresholdsAndDisabled(t *testing.T) {
bans := newTestBanService(t)
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
if result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now); err != nil || result.Enabled {
t.Fatalf("disabled RecordAbuse = %+v, %v", result, err)
}
settings, err := bans.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
if err := bans.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
for i := 0; i < 2; i++ {
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(time.Duration(i)*time.Minute))
if err != nil || result.Triggered {
t.Fatalf("RecordAbuse before threshold = %+v, %v", result, err)
}
}
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(3*time.Minute))
if err != nil || !result.Triggered || result.Ban.ID == "" {
t.Fatalf("RecordAbuse threshold = %+v, %v", result, err)
}
again, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(4*time.Minute))
if err != nil || !again.Triggered || again.Ban.ID != result.Ban.ID {
t.Fatalf("RecordAbuse duplicate = %+v, %v", again, err)
}
records, err := bans.ListBans()
if err != nil {
t.Fatalf("ListBans returned error: %v", err)
}
if len(records) != 1 {
t.Fatalf("ban count = %d, want 1", len(records))
}
}
func TestBanServiceMaliciousPathRules(t *testing.T) {
bans := newTestBanService(t)
if pattern, err := bans.MaliciousPattern("/foo/.ENV"); err != nil || pattern == "" {
t.Fatalf("MaliciousPattern .env = %q, %v", pattern, err)
}
if pattern, err := bans.MaliciousPattern("/static/.env"); err != nil || pattern != "" {
t.Fatalf("MaliciousPattern static = %q, %v", pattern, err)
}
if err := bans.SaveRules([]string{"/custom-probe"}, time.Now().UTC()); err != nil {
t.Fatalf("SaveRules returned error: %v", err)
}
if pattern, err := bans.MaliciousPattern("/x/CUSTOM-probe"); err != nil || pattern != "/custom-probe" {
t.Fatalf("MaliciousPattern custom = %q, %v", pattern, err)
}
}
func newTestBanService(t *testing.T) *BanService {
t.Helper()
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := upload.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
bans, err := NewBanService(upload.DB())
if err != nil {
t.Fatalf("NewBanService returned error: %v", err)
}
return bans
}

View File

@@ -0,0 +1,140 @@
package services
import (
"context"
"net"
"net/http"
"strings"
)
type clientIPContextKey struct{}
func WithClientIP(r *http.Request, ip string) *http.Request {
return r.WithContext(context.WithValue(r.Context(), clientIPContextKey{}, ip))
}
func ClientIPFromContext(r *http.Request) (string, bool) {
ip, ok := r.Context().Value(clientIPContextKey{}).(string)
return ip, ok && ip != ""
}
// ClientIP resolves the effective client IP. When trustedProxies is empty,
// forwarded headers are trusted for easy reverse-proxy/container defaults.
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
remoteIP := IPOnly(remoteAddr)
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {
if ip := firstForwardedIP(forwardedFor); ip != "" {
return IPOnly(ip)
}
if ip := strings.TrimSpace(realIP); ip != "" {
return IPOnly(ip)
}
}
return remoteIP
}
func IPOnly(remoteAddr string) string {
host := strings.TrimSpace(remoteAddr)
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = splitHost
}
return strings.Trim(host, "[]")
}
func IsProtectedProxyIP(ip string, trustedProxies []string) bool {
parsed := net.ParseIP(IPOnly(ip))
if parsed == nil {
return false
}
if parsed.IsLoopback() {
return true
}
return remoteTrusted(parsed.String(), trustedProxies)
}
func ProtectedBanTarget(target string, trustedProxies []string) bool {
normalized, err := NormalizeBanTarget(target)
if err != nil {
return false
}
if !strings.Contains(normalized, "/") {
return IsProtectedProxyIP(normalized, trustedProxies)
}
_, targetNet, err := net.ParseCIDR(normalized)
if err != nil {
return false
}
if targetNet.Contains(net.ParseIP("127.0.0.1")) || targetNet.Contains(net.ParseIP("::1")) {
return true
}
for _, trusted := range trustedProxies {
trusted = strings.TrimSpace(trusted)
if trusted == "" {
continue
}
if strings.Contains(trusted, "/") {
if _, trustedNet, err := net.ParseCIDR(trusted); err == nil && networksOverlap(targetNet, trustedNet) {
return true
}
continue
}
if ip := net.ParseIP(IPOnly(trusted)); ip != nil && targetNet.Contains(ip) {
return true
}
}
return false
}
func firstForwardedIP(forwardedFor string) string {
var fallback string
for _, part := range strings.Split(forwardedFor, ",") {
ip := IPOnly(part)
if net.ParseIP(ip) == nil {
continue
}
if fallback == "" {
fallback = ip
}
if isExternalIP(ip) {
return ip
}
}
return fallback
}
func remoteTrusted(remoteIP string, trustedProxies []string) bool {
parsed := net.ParseIP(remoteIP)
if parsed == nil {
return false
}
for _, trusted := range trustedProxies {
trusted = strings.TrimSpace(trusted)
if trusted == "" {
continue
}
if strings.Contains(trusted, "/") {
if _, network, err := net.ParseCIDR(trusted); err == nil && network.Contains(parsed) {
return true
}
continue
}
if ip := net.ParseIP(trusted); ip != nil && ip.Equal(parsed) {
return true
}
}
return false
}
func isExternalIP(ip string) bool {
parsed := net.ParseIP(IPOnly(ip))
return parsed != nil &&
!parsed.IsLoopback() &&
!parsed.IsPrivate() &&
!parsed.IsLinkLocalUnicast() &&
!parsed.IsLinkLocalMulticast() &&
!parsed.IsUnspecified()
}
func networksOverlap(a, b *net.IPNet) bool {
return a.Contains(b.IP) || b.Contains(a.IP)
}

View File

@@ -0,0 +1,74 @@
package services
import "testing"
func TestClientIPTrustsForwardedHeadersByDefault(t *testing.T) {
ip := ClientIP("127.0.0.1:6070", "203.0.113.10, 10.0.0.2", "198.51.100.2", nil)
if ip != "203.0.113.10" {
t.Fatalf("ClientIP = %q, want forwarded IP", ip)
}
}
func TestClientIPUsesTrustedProxyCIDRs(t *testing.T) {
trusted := []string{"127.0.0.1", "172.16.0.0/12"}
ip := ClientIP("172.20.0.4:6070", "203.0.113.11", "", trusted)
if ip != "203.0.113.11" {
t.Fatalf("trusted ClientIP = %q", ip)
}
spoofed := ClientIP("198.51.100.20:6070", "203.0.113.12", "203.0.113.13", trusted)
if spoofed != "198.51.100.20" {
t.Fatalf("untrusted ClientIP = %q, want remote addr", spoofed)
}
}
func TestClientIPFallsBackToRealIP(t *testing.T) {
ip := ClientIP("127.0.0.1:6070", "", "203.0.113.14", nil)
if ip != "203.0.113.14" {
t.Fatalf("ClientIP = %q, want real IP", ip)
}
}
func TestClientIPStripsPortsFromForwardedHeaders(t *testing.T) {
ip := ClientIP("127.0.0.1:6070", "203.0.113.15:49152", "", nil)
if ip != "203.0.113.15" {
t.Fatalf("ClientIP = %q, want forwarded IP without port", ip)
}
}
func TestClientIPPrefersExternalForwardedAddress(t *testing.T) {
ip := ClientIP("127.0.0.1:6070", "172.30.0.1, 198.51.100.30", "", nil)
if ip != "198.51.100.30" {
t.Fatalf("ClientIP = %q, want public forwarded IP", ip)
}
}
func TestIPOnlyHandlesIPv6HostPort(t *testing.T) {
ip := IPOnly("[2001:db8::1]:6070")
if ip != "2001:db8::1" {
t.Fatalf("IPOnly = %q, want IPv6 address without port", ip)
}
}
func TestProtectedProxyIP(t *testing.T) {
trusted := []string{"127.0.0.1", "172.30.0.1", "10.88.0.0/16"}
for _, ip := range []string{"127.0.0.1:48122", "172.30.0.1", "10.88.0.12"} {
if !IsProtectedProxyIP(ip, trusted) {
t.Fatalf("IsProtectedProxyIP(%q) = false, want true", ip)
}
}
if IsProtectedProxyIP("203.0.113.50", trusted) {
t.Fatalf("external IP treated as protected")
}
}
func TestProtectedBanTarget(t *testing.T) {
trusted := []string{"172.30.0.1", "10.88.0.0/16"}
for _, target := range []string{"127.0.0.1", "172.30.0.1", "172.30.0.0/24", "10.88.12.0/24"} {
if !ProtectedBanTarget(target, trusted) {
t.Fatalf("ProtectedBanTarget(%q) = false, want true", target)
}
}
if ProtectedBanTarget("203.0.113.0/24", trusted) {
t.Fatalf("external target treated as protected")
}
}

View File

@@ -0,0 +1,166 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var reactionsBucket = []byte("file_reactions")
type ReactionService struct {
db *bbolt.DB
}
type FileReaction struct {
BoxID string `json:"boxId"`
FileID string `json:"fileId"`
EmojiID string `json:"emojiId"`
VisitorHash string `json:"visitorHash"`
CreatedAt time.Time `json:"createdAt"`
}
type ReactionSummary struct {
EmojiID string `json:"emojiId"`
Count int `json:"count"`
}
func NewReactionService(db *bbolt.DB) (*ReactionService, error) {
if err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(reactionsBucket)
return err
}); err != nil {
return nil, err
}
return &ReactionService{db: db}, nil
}
func (s *ReactionService) Add(boxID, fileID, visitorID, emojiID string) ([]ReactionSummary, error) {
boxID = strings.TrimSpace(boxID)
fileID = strings.TrimSpace(fileID)
visitorHash := reactionVisitorHash(visitorID)
emojiID = strings.TrimSpace(emojiID)
if boxID == "" || fileID == "" || visitorHash == "" || emojiID == "" {
return nil, errors.New("missing reaction data")
}
reaction := FileReaction{
BoxID: boxID,
FileID: fileID,
EmojiID: emojiID,
VisitorHash: visitorHash,
CreatedAt: time.Now().UTC(),
}
data, err := json.Marshal(reaction)
if err != nil {
return nil, err
}
key := reactionKey(boxID, fileID, visitorHash)
if err := s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket.Get([]byte(key)) != nil {
return os.ErrExist
}
return bucket.Put([]byte(key), data)
}); err != nil {
return nil, err
}
return s.SummaryForFile(boxID, fileID)
}
func (s *ReactionService) SummaryForBox(boxID, visitorID string) (map[string][]ReactionSummary, map[string]bool, error) {
visitorHash := reactionVisitorHash(visitorID)
summaries := make(map[string]map[string]int)
viewerReacted := make(map[string]bool)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, data []byte) error {
var reaction FileReaction
if err := json.Unmarshal(data, &reaction); err != nil {
return err
}
if reaction.BoxID != boxID {
return nil
}
if summaries[reaction.FileID] == nil {
summaries[reaction.FileID] = make(map[string]int)
}
summaries[reaction.FileID][reaction.EmojiID]++
if visitorHash != "" && reaction.VisitorHash == visitorHash {
viewerReacted[reaction.FileID] = true
}
return nil
})
})
if err != nil {
return nil, nil, err
}
result := make(map[string][]ReactionSummary, len(summaries))
for fileID, counts := range summaries {
result[fileID] = reactionCountsToSummaries(counts)
}
return result, viewerReacted, nil
}
func (s *ReactionService) SummaryForFile(boxID, fileID string) ([]ReactionSummary, error) {
counts := make(map[string]int)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(reactionsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, data []byte) error {
var reaction FileReaction
if err := json.Unmarshal(data, &reaction); err != nil {
return err
}
if reaction.BoxID == boxID && reaction.FileID == fileID {
counts[reaction.EmojiID]++
}
return nil
})
})
if err != nil {
return nil, err
}
return reactionCountsToSummaries(counts), nil
}
func reactionCountsToSummaries(counts map[string]int) []ReactionSummary {
summaries := make([]ReactionSummary, 0, len(counts))
for emojiID, count := range counts {
summaries = append(summaries, ReactionSummary{EmojiID: emojiID, Count: count})
}
sort.Slice(summaries, func(i, j int) bool {
if summaries[i].Count == summaries[j].Count {
return summaries[i].EmojiID < summaries[j].EmojiID
}
return summaries[i].Count > summaries[j].Count
})
return summaries
}
func reactionKey(boxID, fileID, visitorHash string) string {
return boxID + "\x00" + fileID + "\x00" + visitorHash
}
func reactionVisitorHash(visitorID string) string {
visitorID = strings.TrimSpace(visitorID)
if visitorID == "" {
return ""
}
sum := sha256.Sum256([]byte(visitorID))
return hex.EncodeToString(sum[:])
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"net"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -234,6 +233,29 @@ func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) erro
}) })
} }
func (s *SettingsService) ResetStorageBackend(backendID string) (bool, bool, error) {
backendID = strings.TrimSpace(backendID)
if backendID == "" || backendID == StorageBackendLocal {
return false, false, nil
}
settings, err := s.UploadPolicy()
if err != nil {
return false, false, err
}
resetAnonymous := settings.AnonymousStorageBackend == backendID
resetUser := settings.UserStorageBackend == backendID
if !resetAnonymous && !resetUser {
return false, false, nil
}
if resetAnonymous {
settings.AnonymousStorageBackend = StorageBackendLocal
}
if resetUser {
settings.UserStorageBackend = StorageBackendLocal
}
return resetAnonymous, resetUser, s.UpdateUploadPolicy(settings)
}
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) { func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
key := usageKey(subjectType, subject, now) key := usageKey(subjectType, subject, now)
var record UsageRecord var record UsageRecord
@@ -484,19 +506,3 @@ func normalizeBackendID(id string) string {
} }
return id 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

@@ -86,6 +86,7 @@ type StorageBackendView struct {
UsageBytes int64 UsageBytes int64
UsageLabel string UsageLabel string
InUse bool InUse bool
InUseReason string
SpeedTests []StorageSpeedTest SpeedTests []StorageSpeedTest
CanSpeedTest bool CanSpeedTest bool
} }
@@ -132,6 +133,14 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) {
return s.backendFromConfig(cfg) return s.backendFromConfig(cfg)
} }
func (s *StorageService) BackendForMaintenance(id string) (StorageBackend, error) {
cfg, err := s.BackendConfig(id)
if err != nil {
return nil, err
}
return s.backendFromConfig(cfg)
}
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) { func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" || id == StorageBackendLocal { if id == "" || id == StorageBackendLocal {
@@ -340,21 +349,6 @@ func (s *StorageService) SaveBackendConfig(cfg StorageBackendConfig) error {
}) })
} }
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 { func (s *StorageService) DeleteBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal { if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be deleted") return fmt.Errorf("local storage cannot be deleted")

View File

@@ -85,6 +85,7 @@ type UploadResult struct {
BoxID string `json:"boxId"` BoxID string `json:"boxId"`
BoxURL string `json:"boxUrl"` BoxURL string `json:"boxUrl"`
ZipURL string `json:"zipUrl"` ZipURL string `json:"zipUrl"`
ThumbnailURL string `json:"thumbnailUrl"`
ManageURL string `json:"manageUrl"` ManageURL string `json:"manageUrl"`
DeleteURL string `json:"deleteUrl"` DeleteURL string `json:"deleteUrl"`
ExpiresAt string `json:"expiresAt"` ExpiresAt string `json:"expiresAt"`
@@ -96,6 +97,7 @@ type ResultFile struct {
Name string `json:"name"` Name string `json:"name"`
Size string `json:"size"` Size string `json:"size"`
URL string `json:"url"` URL string `json:"url"`
ThumbnailURL string `json:"thumbnailUrl"`
} }
type AdminStats struct { type AdminStats struct {
@@ -135,6 +137,9 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
if err := os.MkdirAll(dbDir, 0o755); err != nil { if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, err return nil, err
} }
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
return nil, err
}
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second}) db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
if err != nil { if err != nil {
@@ -196,14 +201,21 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
if len(files) == 0 { if len(files) == 0 {
return UploadResult{}, fmt.Errorf("no files were uploaded") return UploadResult{}, fmt.Errorf("no files were uploaded")
} }
if opts.MaxDays <= 0 {
opts.MaxDays = 7
}
now := time.Now().UTC() now := time.Now().UTC()
expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour) var expiresAt time.Time
if opts.ExpiresInMinutes > 0 { switch {
case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0:
// "Forever" — a date far enough out that the box effectively never
// expires. No schema change; CanDownload/cleanup keep working as-is.
expiresAt = now.AddDate(100, 0, 0)
case opts.ExpiresInMinutes > 0:
expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute) expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute)
default:
days := opts.MaxDays
if days <= 0 {
days = 7
}
expiresAt = now.Add(time.Duration(days) * 24 * time.Hour)
} }
box := Box{ box := Box{
@@ -226,15 +238,66 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
box.PasswordHash = hash box.PasswordHash = hash
} }
backend, err := s.storage.Backend(box.StorageBackendID) if err := s.writeFilesToBox(&box, files, opts); err != nil {
return UploadResult{}, err
}
if err := s.SaveBox(box); err != nil {
return UploadResult{}, err
}
s.logger.Info("upload complete",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"file_count", len(box.Files),
)
return s.resultForBox(box, deleteToken), nil
}
// AppendFiles adds files to an existing box (used to group a ShareX multi-file
// selection into a single box). The box keeps its original expiry, password and
// other settings; only the new files are written.
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
if len(files) == 0 {
return UploadResult{}, fmt.Errorf("no files were uploaded")
}
box, err := s.GetBox(boxID)
if err != nil { if err != nil {
return UploadResult{}, err return UploadResult{}, err
} }
if err := s.writeFilesToBox(&box, files, opts); err != nil {
return UploadResult{}, err
}
if err := s.SaveBox(box); err != nil {
return UploadResult{}, err
}
s.logger.Info("upload appended",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"added", len(files),
"file_count", len(box.Files),
)
return s.resultForBox(box, ""), nil
}
// writeFilesToBox streams each uploaded file into the box's storage backend and
// appends the file metadata to box.Files. The box's StorageBackendID determines
// where files land, so it works for both new and existing boxes.
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil {
return err
}
for _, header := range files { for _, header := range files {
if !opts.SkipSizeLimit { if !opts.SkipSizeLimit {
if err := s.ValidateSize(header.Size); err != nil { if err := s.ValidateSize(header.Size); err != nil {
return UploadResult{}, err return err
} }
} }
@@ -245,7 +308,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
file, err := header.Open() file, err := header.Open()
if err != nil { if err != nil {
return UploadResult{}, err return err
} }
fileID := randomID(8) fileID := randomID(8)
@@ -263,7 +326,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil { if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
file.Close() file.Close()
return UploadResult{}, err return err
} }
file.Close() file.Close()
@@ -278,20 +341,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
UploadedAt: time.Now().UTC(), UploadedAt: time.Now().UTC(),
}) })
} }
return nil
if err := s.SaveBox(box); err != nil {
return UploadResult{}, err
}
s.logger.Info("upload complete",
"source", "user-upload",
"severity", "user_activity",
"code", 2001,
"box_id", box.ID,
"file_count", len(box.Files),
)
return s.resultForBox(box, deleteToken), nil
} }
func (s *UploadService) GetBox(id string) (Box, error) { func (s *UploadService) GetBox(id string) (Box, error) {
@@ -506,6 +556,28 @@ func (s *UploadService) DeleteBox(boxID string) error {
return s.DeleteBoxWithSource(boxID, "admin") return s.DeleteBoxWithSource(boxID, "admin")
} }
func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) {
backendID = normalizeBackendID(backendID)
if backendID == StorageBackendLocal {
return 0, fmt.Errorf("local storage cannot be deleted")
}
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
deleted := 0
for _, box := range boxes {
if s.BoxStorageBackendID(box) != backendID {
continue
}
if err := s.DeleteBoxWithSource(box.ID, source); err != nil {
return deleted, err
}
deleted++
}
return deleted, nil
}
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error { func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
box, err := s.GetBox(boxID) box, err := s.GetBox(boxID)
if err != nil { if err != nil {
@@ -525,7 +597,12 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
return err return err
} }
if box.ID != "" { if box.ID != "" {
if backend, err := s.storage.Backend(s.BoxStorageBackendID(box)); err == nil { backendID := s.BoxStorageBackendID(box)
backend, err := s.storage.Backend(backendID)
if err != nil {
backend, err = s.storage.BackendForMaintenance(backendID)
}
if err == nil {
if err := backend.DeletePrefix(context.Background(), box.ID); err != nil { if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
return err return err
} }
@@ -539,6 +616,80 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
return nil return nil
} }
// RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and
// removes it from the box. If it was the box's last file, the whole box is
// deleted. Returns whether the box itself was removed.
func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
box, err := s.GetBox(boxID)
if err != nil {
return false, err
}
index := -1
for i, file := range box.Files {
if file.ID == fileID {
index = i
break
}
}
if index < 0 {
return false, os.ErrNotExist
}
file := box.Files[index]
backendID := s.BoxStorageBackendID(box)
backend, err := s.storage.Backend(backendID)
if err != nil {
backend, err = s.storage.BackendForMaintenance(backendID)
}
if err == nil {
if key := s.FileObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
if key := s.ThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
}
box.Files = append(box.Files[:index], box.Files[index+1:]...)
if len(box.Files) == 0 {
if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil {
return false, err
}
return true, nil
}
if err := s.SaveBox(box); err != nil {
return false, err
}
s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID)
return false, nil
}
// AdminUpdateBox lets an admin change a box's expiry, download limit, and
// optionally clear password protection.
func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if !expiresAt.IsZero() {
box.ExpiresAt = expiresAt.UTC()
}
if maxDownloads < 0 {
maxDownloads = 0
}
box.MaxDownloads = maxDownloads
if removePassword {
box.PasswordHash = ""
box.PasswordSalt = ""
box.Obfuscate = false
}
if err := s.SaveBox(box); err != nil {
return err
}
s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID)
return nil
}
func (s *UploadService) FindFile(box Box, fileID string) (File, error) { func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
for _, file := range box.Files { for _, file := range box.Files {
if file.ID == fileID { if file.ID == fileID {
@@ -730,13 +881,22 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
Name: file.Name, Name: file.Name,
Size: helpers.FormatBytes(file.Size), Size: helpers.FormatBytes(file.Size),
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID), URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
}) })
} }
// The box-level thumbnail points at the most recently added file, so a
// per-file ShareX upload previews the file it just sent.
thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
if len(files) > 0 {
thumbnailURL = files[len(files)-1].ThumbnailURL
}
result := UploadResult{ result := UploadResult{
BoxID: box.ID, BoxID: box.ID,
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID), BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID), ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
ThumbnailURL: thumbnailURL,
ExpiresAt: box.ExpiresAt.Format(time.RFC3339), ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
Files: files, Files: files,
} }
@@ -800,6 +960,10 @@ func randomID(byteCount int) string {
return base64.RawURLEncoding.EncodeToString(data) return base64.RawURLEncoding.EncodeToString(data)
} }
func RandomPublicToken(byteCount int) string {
return randomID(byteCount)
}
func hashPassword(password string) (string, string) { func hashPassword(password string) (string, string) {
salt := randomID(18) salt := randomID(18)
return salt, passwordHash(salt, password) return salt, passwordHash(salt, password)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

View File

@@ -58,6 +58,69 @@
--surface-2: rgba(39, 39, 42, 0.28); --surface-2: rgba(39, 39, 42, 0.28);
} }
:root[data-theme="gruvbox"] {
color-scheme: dark;
--background: #1d2021;
--foreground: #ebdbb2;
--card: #282828;
--card-foreground: #ebdbb2;
--muted: #32302f;
--muted-foreground: #bdae93;
--accent: #3c3836;
--accent-foreground: #fbf1c7;
--border: rgba(235, 219, 178, 0.18);
--input: rgba(235, 219, 178, 0.24);
--primary: #d79921;
--primary-foreground: #1d2021;
--primary-hover: #fabd2f;
--ring: #fe8019;
--success: #b8bb26;
--danger: #fb4934;
--radius: 0.65rem;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--header-bg: rgba(29, 32, 33, 0.86);
--body-bg:
radial-gradient(circle at 20% -8%, rgba(215, 153, 33, 0.2), transparent 28rem),
radial-gradient(circle at 85% 8%, rgba(184, 187, 38, 0.12), transparent 26rem),
linear-gradient(180deg, #1d2021 0%, #181a1b 100%);
--surface-1: rgba(235, 219, 178, 0.06);
--surface-1-hover: rgba(235, 219, 178, 0.11);
--surface-2: rgba(251, 241, 199, 0.04);
}
:root[data-theme="cyberpunk"] {
color-scheme: dark;
--background: #08070d;
--foreground: #fff36f;
--card: #16131f;
--card-foreground: #fff36f;
--muted: #251d34;
--muted-foreground: #9bfaff;
--accent: #332246;
--accent-foreground: #fff36f;
--border: rgba(255, 242, 0, 0.24);
--input: rgba(0, 240, 255, 0.34);
--primary: #fff200;
--primary-foreground: #08070d;
--primary-hover: #00f0ff;
--ring: #ff2a6d;
--success: #00ff9f;
--danger: #ff2a6d;
--radius: 0.35rem;
--shadow: 0 24px 70px rgba(255, 42, 109, 0.16), 0 0 34px rgba(0, 240, 255, 0.12);
--font-sans: "Inter", "Rajdhani", "Orbitron", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--header-bg: rgba(8, 7, 13, 0.86);
--body-bg:
radial-gradient(circle at 10% -10%, rgba(255, 242, 0, 0.2), transparent 26rem),
radial-gradient(circle at 90% 8%, rgba(0, 240, 255, 0.18), transparent 26rem),
radial-gradient(circle at 45% 110%, rgba(255, 42, 109, 0.18), transparent 30rem),
linear-gradient(180deg, #08070d 0%, #120b1a 100%);
--surface-1: rgba(0, 240, 255, 0.07);
--surface-1-hover: rgba(255, 242, 0, 0.12);
--surface-2: rgba(255, 42, 109, 0.06);
}
:root[data-theme="retro"] { :root[data-theme="retro"] {
color-scheme: light; color-scheme: light;
--background: #ffffff; --background: #ffffff;
@@ -98,6 +161,7 @@ html {
font-family: var(--font-sans); font-family: var(--font-sans);
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
overflow-x: clip;
} }
body { body {
@@ -107,12 +171,27 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--body-bg); background: var(--body-bg);
overflow-x: clip;
}
@supports not (overflow-x: clip) {
html,
body {
overflow-x: hidden;
}
} }
a { a {
color: inherit; color: inherit;
} }
img,
video,
canvas,
iframe {
max-width: 100%;
}
svg { svg {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
@@ -176,10 +255,18 @@ svg {
} }
.brand { .brand {
min-width: 0;
font-weight: 650; font-weight: 650;
text-decoration: none; text-decoration: none;
} }
.brand > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand-mark { .brand-mark {
width: 1.75rem; width: 1.75rem;
height: 1.75rem; height: 1.75rem;
@@ -312,12 +399,15 @@ label span {
input, input,
select, select,
textarea,
button { button {
font: inherit; font: inherit;
max-width: 100%;
} }
input, input,
select { select,
textarea {
width: 100%; width: 100%;
min-height: 2.25rem; min-height: 2.25rem;
border: 1px solid var(--input); border: 1px solid var(--input);
@@ -354,6 +444,8 @@ input:disabled {
.button, .button,
button { button {
min-width: 0;
max-width: 100%;
min-height: 2.25rem; min-height: 2.25rem;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -372,6 +464,14 @@ button {
cursor: pointer; cursor: pointer;
} }
.button > span,
button > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.button-primary { .button-primary {
background: var(--primary); background: var(--primary);
color: var(--primary-foreground); color: var(--primary-foreground);
@@ -433,6 +533,8 @@ pre code {
.badge { .badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
max-width: 100%;
min-width: 0;
min-height: 1.5rem; min-height: 1.5rem;
border-radius: 999px; border-radius: 999px;
background: var(--muted); background: var(--muted);
@@ -440,6 +542,9 @@ pre code {
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.sr-only { .sr-only {

View File

@@ -1,5 +1,6 @@
.app-shell { .app-shell {
width: min(86rem, calc(100% - 2rem)); width: min(86rem, calc(100% - 2rem));
max-width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 2rem 0; padding: 2rem 0;
display: grid; display: grid;
@@ -8,6 +9,7 @@
} }
.app-sidebar { .app-sidebar {
min-width: 0;
position: sticky; position: sticky;
top: 5rem; top: 5rem;
align-self: start; align-self: start;
@@ -20,6 +22,7 @@
} }
.sidebar-link { .sidebar-link {
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.55rem;
@@ -30,6 +33,13 @@
text-decoration: none; text-decoration: none;
} }
.sidebar-link span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-link:hover, .sidebar-link:hover,
.sidebar-link.is-active { .sidebar-link.is-active {
border-color: var(--border); border-color: var(--border);
@@ -100,7 +110,7 @@
.inline-controls input, .inline-controls input,
.inline-controls select { .inline-controls select {
min-width: 15rem; min-width: min(15rem, 100%);
} }
.compact-input { .compact-input {
@@ -108,10 +118,18 @@
} }
.settings-form { .settings-form {
min-width: 0;
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
} }
.settings-form > *,
.settings-section > *,
.tabs-bar > *,
.tab-list > * {
min-width: 0;
}
.settings-form-narrow { .settings-form-narrow {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 0.9rem; gap: 0.9rem;
@@ -207,6 +225,7 @@
top: calc(100% + 0.5rem); top: calc(100% + 0.5rem);
z-index: 10; z-index: 10;
width: 15rem; width: 15rem;
max-width: min(15rem, calc(100vw - 2rem));
padding: 1rem; padding: 1rem;
background: color-mix(in srgb, var(--card) 97%, #000); background: color-mix(in srgb, var(--card) 97%, #000);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -226,6 +245,7 @@
/* Copyable URL field */ /* Copyable URL field */
.copy-field { .copy-field {
display: flex; display: flex;
min-width: 0;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
margin-top: 0.75rem; margin-top: 0.75rem;

View File

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

View File

@@ -150,17 +150,18 @@
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf; 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. */ /* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
:root[data-theme="retro"] a:not(.button):not(.brand) { are styled as their own Win98 controls below, so they're excluded here. */
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) {
color: #0000ee; color: #0000ee;
text-decoration: underline; text-decoration: underline;
} }
:root[data-theme="retro"] a:not(.button):not(.brand):visited { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited {
color: #551a8b; color: #551a8b;
} }
:root[data-theme="retro"] a:not(.button):not(.brand):hover { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover {
color: #ee0000; color: #ee0000;
} }
@@ -188,14 +189,25 @@
padding-left: calc(0.85rem + 1px); padding-left: calc(0.85rem + 1px);
} }
/* The primary call-to-action gets the blue title-bar gradient. */ /* The primary call-to-action is a glossy raised blue button. A vertical
gradient + strong 3D bevel keeps it clearly a button (and distinct from the
horizontal title-bar gradient). */
:root[data-theme="retro"] .button-primary { :root[data-theme="retro"] .button-primary {
background: linear-gradient(to right, #000078, 80%, #0f80cd); background: linear-gradient(to bottom, #2f86e0 0%, #0a3aa0 52%, #000078 100%);
color: #ffffff; color: #ffffff;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #00003a, inset 1px 1px 0 #7fc0ff, inset -2px -2px 0 #001a6a, inset 2px 2px 0 #3f9fe8;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.4);
} }
:root[data-theme="retro"] .button-primary:hover { :root[data-theme="retro"] .button-primary:hover {
background: linear-gradient(to right, #0a0a9a, 80%, #1a90dd); filter: brightness(1.08);
}
:root[data-theme="retro"] .button-primary:active {
box-shadow: inset 1px 1px 0 #00003a, inset -1px -1px 0 #7fc0ff;
padding-top: calc(0.45rem + 1px);
padding-left: calc(0.85rem + 1px);
} }
:root[data-theme="retro"] .button-danger { :root[data-theme="retro"] .button-danger {
@@ -276,7 +288,8 @@
the API section cards. Pages where a heading sits below an icon or kicker 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. */ (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"] .card-content > h1:first-child,
:root[data-theme="retro"] .docs-header h1 { :root[data-theme="retro"] .docs-header h1,
:root[data-theme="retro"] .download-view-wide .download-card h1 {
margin: -1.5rem -1.5rem 1rem; margin: -1.5rem -1.5rem 1rem;
} }
@@ -440,3 +453,291 @@
display: none; display: none;
} }
} }
/* ------------------------------------------------------------------------- */
/* App / admin shell (dashboard, account, admin pages) */
/* These use dark revamp tokens by default, which are unreadable on the black */
/* retro desktop. Re-skin them as Win98 chrome: a raised silver sidebar with */
/* solid links, light page headers on the desktop, and bevelled stat cards. */
/* ------------------------------------------------------------------------- */
/* Sidebar = raised silver panel. */
:root[data-theme="retro"] .app-sidebar,
:root[data-theme="retro"] .admin-shell .app-sidebar {
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
}
:root[data-theme="retro"] .sidebar-link {
color: #000000;
border: 1px solid transparent;
font-weight: 700;
}
:root[data-theme="retro"] .sidebar-link:hover,
:root[data-theme="retro"] .sidebar-link.is-active,
:root[data-theme="retro"] .admin-shell .sidebar-link.is-active {
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
border-color: #000000;
}
:root[data-theme="retro"] .sidebar-sep {
background: #808080;
height: 2px;
box-shadow: 0 1px 0 #ffffff;
}
/* Page header sits on the black desktop: light kicker, plain light title
(not a floating title bar), light subtitle. */
:root[data-theme="retro"] .admin-header .kicker {
color: #ffd966;
}
:root[data-theme="retro"] .admin-header .muted-copy {
color: #cfd8ff;
}
:root[data-theme="retro"] .admin-header h1 {
margin: 0;
padding: 0;
display: block;
background: none;
color: #ffffff;
}
:root[data-theme="retro"] .admin-header h1::after {
content: none;
}
/* Collection / nav tabs become small bevelled buttons. */
:root[data-theme="retro"] .tab {
background: #c0c0c0;
color: #000000;
border: 1px solid #000000;
font-weight: 700;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .tab:hover {
background: #d4d0c8;
color: #000000;
}
:root[data-theme="retro"] .tab.is-active {
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
}
/* Metric cards = sunken white stat boxes with crisp black numbers. */
:root[data-theme="retro"] .metric-card {
background: #ffffff;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .metric-card span {
color: #404040;
}
:root[data-theme="retro"] .metric-card strong {
color: #000000;
}
/* The "+ Collection" popover becomes a small floating window. */
:root[data-theme="retro"] .new-collection-body {
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
color: #000000;
}
/* The storage inline edit form panel. */
:root[data-theme="retro"] .storage-edit-form {
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
}
/* ------------------------------------------------------------------------- */
/* Download / box page */
/* ------------------------------------------------------------------------- */
/* The decorative file glyph above the title doesn't suit a Win98 window. */
:root[data-theme="retro"] .file-emblem {
display: none;
}
/* The download window's content is left-aligned like a real file manager. */
:root[data-theme="retro"] .download-view-wide .download-card {
text-align: left;
}
/* Expiry shown as a sunken status field with a little clock. */
:root[data-theme="retro"] .badge-row {
justify-content: flex-start;
}
:root[data-theme="retro"] .badge-expiry {
background: #ffffff;
color: #000000;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
font-weight: 700;
padding: 0.3rem 0.7rem;
}
:root[data-theme="retro"] .badge-expiry::before {
content: "\23F1 ";
}
/* The file browser becomes a Win98 Explorer window: blue titlebar, grey
toolbar, sunken content pane and flat rows. */
:root[data-theme="retro"] .file-browser-window {
border: 1px solid #000000;
border-radius: 0;
background: #c0c0c0;
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"] .file-browser-titlebar {
min-height: 1.8rem;
margin: 3px 3px 0;
padding: 0.2rem 0.45rem;
border: 0;
background: linear-gradient(to right, #000078 0%, #000078 80%, #0f80cd 100%);
color: #ffffff;
}
:root[data-theme="retro"] .file-browser-titlebar strong,
:root[data-theme="retro"] .file-browser-titlebar span {
color: #ffffff;
font-size: 0.78rem;
}
:root[data-theme="retro"] .file-browser-window-actions {
display: none;
}
:root[data-theme="retro"] .file-browser-toolbar {
justify-content: space-between;
margin: 0 3px;
padding: 3px;
border: 0;
border-bottom: 1px solid #808080;
background: #c0c0c0;
}
:root[data-theme="retro"] .view-toolbar {
justify-content: flex-start;
gap: 2px;
margin-top: 0;
padding: 0;
background: #c0c0c0;
border: 0;
box-shadow: none;
}
:root[data-theme="retro"] .view-toolbar .button,
:root[data-theme="retro"] .file-browser-toolbar > .button {
display: inline-grid;
place-items: center;
background: transparent;
border: 1px solid transparent;
box-shadow: none;
font-weight: 400;
}
:root[data-theme="retro"] .view-toolbar .icon-button {
width: 2.2rem;
height: 2rem;
padding: 0;
}
:root[data-theme="retro"] .view-toolbar .icon-button svg {
margin: 0;
display: block;
}
:root[data-theme="retro"] .view-toolbar .button:hover,
:root[data-theme="retro"] .file-browser-toolbar > .button:hover {
background: #c0c0c0;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
}
:root[data-theme="retro"] .view-toolbar .button.is-active,
:root[data-theme="retro"] .file-browser-toolbar > .button.is-active {
background: #d4d0c8;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .file-browser-head {
margin: 0 3px;
border: 0;
border-bottom: 1px solid #808080;
background: #c0c0c0;
color: #000000;
text-transform: none;
}
:root[data-theme="retro"] .file-browser {
margin: 0 3px 3px;
border: 1px solid #000000;
background: #ffffff;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .download-item {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
:root[data-theme="retro"] .file-open {
border-radius: 0;
color: #000000;
}
:root[data-theme="retro"] .file-open:hover,
:root[data-theme="retro"] .file-open:focus-visible {
background: transparent;
color: #000000;
outline: 2px solid #000078;
outline-offset: -2px;
}
:root[data-theme="retro"] .file-browser.is-list .file-card:hover,
:root[data-theme="retro"] .file-browser.is-list .file-card:focus-within {
background: transparent;
outline: 2px solid #000078;
outline-offset: -2px;
}
:root[data-theme="retro"] .file-browser.is-list .file-card:hover .file-open,
:root[data-theme="retro"] .file-browser.is-list .file-card:focus-within .file-open {
outline: 0;
}
:root[data-theme="retro"] .file-media {
border: 0;
border-radius: 0;
background: transparent;
}
:root[data-theme="retro"] .file-browser.is-thumbs .file-open {
align-content: start;
justify-content: center;
}
:root[data-theme="retro"] .file-browser.is-thumbs .file-media {
justify-self: center;
align-self: start;
}
:root[data-theme="retro"] .file-type,
:root[data-theme="retro"] .file-size,
:root[data-theme="retro"] .file-main small {
color: inherit;
}

View File

@@ -0,0 +1,88 @@
/*
* Gruvbox theme polish.
*
* Core colour tokens live in 00-base.css. This file adds the warmer, grounded
* Gruvbox-specific surface treatment without changing layout behavior.
*/
:root[data-theme="gruvbox"] .site-header {
border-bottom-color: rgba(250, 189, 47, 0.2);
backdrop-filter: blur(16px) saturate(130%);
-webkit-backdrop-filter: blur(16px) saturate(130%);
}
:root[data-theme="gruvbox"] .brand-mark {
background: linear-gradient(135deg, #d79921, #fe8019);
color: #1d2021;
box-shadow: 0 8px 22px rgba(254, 128, 25, 0.22);
}
:root[data-theme="gruvbox"] .card,
:root[data-theme="gruvbox"] .app-sidebar,
:root[data-theme="gruvbox"] .storage-card,
:root[data-theme="gruvbox"] .storage-op-card,
:root[data-theme="gruvbox"] .metric-card,
:root[data-theme="gruvbox"] .logs-filter-card {
background: color-mix(in srgb, var(--card) 92%, #1d2021);
border-color: rgba(235, 219, 178, 0.16);
}
:root[data-theme="gruvbox"] .admin-shell .app-sidebar {
border-color: rgba(250, 189, 47, 0.32);
background: linear-gradient(180deg, rgba(215, 153, 33, 0.12), rgba(40, 40, 40, 0.94));
}
:root[data-theme="gruvbox"] .admin-shell .sidebar-link.is-active {
border-color: rgba(250, 189, 47, 0.36);
background: rgba(215, 153, 33, 0.14);
}
:root[data-theme="gruvbox"] .admin-shell .kicker,
:root[data-theme="gruvbox"] .kicker {
color: #fabd2f;
}
:root[data-theme="gruvbox"] h1 {
color: #fbf1c7;
}
:root[data-theme="gruvbox"] .button-primary,
:root[data-theme="gruvbox"] .button.is-active {
border-color: rgba(250, 189, 47, 0.3);
background: linear-gradient(135deg, #d79921, #fabd2f);
color: #1d2021;
box-shadow: 0 10px 24px rgba(215, 153, 33, 0.2);
}
:root[data-theme="gruvbox"] .button-primary:hover {
background: linear-gradient(135deg, #fabd2f, #fe8019);
}
:root[data-theme="gruvbox"] .button-outline,
:root[data-theme="gruvbox"] .button-ghost:hover,
:root[data-theme="gruvbox"] .button-outline:hover {
border-color: rgba(235, 219, 178, 0.2);
}
:root[data-theme="gruvbox"] .badge-active,
:root[data-theme="gruvbox"] .storage-detail-test.is-ok > span:last-child {
color: #b8bb26;
}
:root[data-theme="gruvbox"] .badge-disabled,
:root[data-theme="gruvbox"] .storage-detail-test.is-err > span:last-child,
:root[data-theme="gruvbox"] .form-error {
color: #fb4934;
}
:root[data-theme="gruvbox"] input:focus,
:root[data-theme="gruvbox"] select:focus,
:root[data-theme="gruvbox"] textarea:focus {
border-color: #fe8019;
box-shadow: 0 0 0 3px rgba(254, 128, 25, 0.18);
}
:root[data-theme="gruvbox"] ::selection {
background: #d79921;
color: #1d2021;
}

View File

@@ -0,0 +1,196 @@
/*
* CyberPunk theme polish.
*
* Inspired by neon Cyberpunk 2077 UI treatments: warning yellow surfaces,
* cyan/magenta light, hard edges, scanlines, and high-contrast panels.
*/
:root[data-theme="cyberpunk"] body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
background-size: 100% 3px, 3rem 100%;
mix-blend-mode: screen;
}
:root[data-theme="cyberpunk"] body::after {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
linear-gradient(115deg, transparent 0 18%, rgba(255, 242, 0, 0.06) 18% 19%, transparent 19% 100%),
linear-gradient(245deg, transparent 0 76%, rgba(255, 42, 109, 0.08) 76% 77%, transparent 77% 100%);
}
:root[data-theme="cyberpunk"] .site-header {
border-bottom-color: rgba(255, 242, 0, 0.32);
box-shadow: 0 0 22px rgba(0, 240, 255, 0.12);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
}
:root[data-theme="cyberpunk"] .brand {
text-transform: lowercase;
letter-spacing: 0.02em;
}
:root[data-theme="cyberpunk"] .brand-mark {
background: #fff200;
color: #08070d;
box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.45), 0 0 18px rgba(255, 242, 0, 0.42);
clip-path: polygon(0 0, 100% 0, 100% 72%, 78% 100%, 0 100%);
}
:root[data-theme="cyberpunk"] h1 {
color: #fff200;
text-shadow: 2px 0 0 rgba(255, 42, 109, 0.58), -2px 0 0 rgba(0, 240, 255, 0.46);
}
:root[data-theme="cyberpunk"] .card,
:root[data-theme="cyberpunk"] .app-sidebar,
:root[data-theme="cyberpunk"] .storage-card,
:root[data-theme="cyberpunk"] .storage-op-card,
:root[data-theme="cyberpunk"] .metric-card,
:root[data-theme="cyberpunk"] .logs-filter-card,
:root[data-theme="cyberpunk"] .advanced-options {
position: relative;
background:
linear-gradient(145deg, rgba(22, 19, 31, 0.96), rgba(13, 10, 20, 0.96)),
linear-gradient(90deg, rgba(255, 242, 0, 0.16), rgba(0, 240, 255, 0.08));
border-color: rgba(255, 242, 0, 0.28);
box-shadow: var(--shadow);
}
:root[data-theme="cyberpunk"] .card::before,
:root[data-theme="cyberpunk"] .storage-card::before,
:root[data-theme="cyberpunk"] .metric-card::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-top: 1px solid rgba(0, 240, 255, 0.4);
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
}
:root[data-theme="cyberpunk"] .admin-shell .app-sidebar {
border-color: rgba(255, 42, 109, 0.38);
background:
linear-gradient(180deg, rgba(255, 42, 109, 0.16), rgba(8, 7, 13, 0.94)),
#16131f;
}
:root[data-theme="cyberpunk"] .sidebar-link:hover,
:root[data-theme="cyberpunk"] .sidebar-link.is-active,
:root[data-theme="cyberpunk"] .admin-shell .sidebar-link.is-active {
border-color: rgba(255, 242, 0, 0.42);
background: linear-gradient(90deg, rgba(255, 242, 0, 0.2), rgba(0, 240, 255, 0.08));
color: #fff200;
}
:root[data-theme="cyberpunk"] .kicker,
:root[data-theme="cyberpunk"] .admin-shell .kicker {
color: #00f0ff;
text-shadow: 0 0 12px rgba(0, 240, 255, 0.36);
}
:root[data-theme="cyberpunk"] .button-primary,
:root[data-theme="cyberpunk"] .button.is-active {
border-color: #fff200;
background: #fff200;
color: #08070d;
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.7), 0 0 18px rgba(255, 242, 0, 0.3);
clip-path: polygon(0 0, calc(100% - 0.7rem) 0, 100% 0.7rem, 100% 100%, 0.7rem 100%, 0 calc(100% - 0.7rem));
}
:root[data-theme="cyberpunk"] .button-primary:hover,
:root[data-theme="cyberpunk"] .button.is-active:hover {
background: #00f0ff;
border-color: #00f0ff;
color: #08070d;
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.78), 0 0 22px rgba(0, 240, 255, 0.42);
}
:root[data-theme="cyberpunk"] .button-outline,
:root[data-theme="cyberpunk"] .button-ghost {
border-color: rgba(0, 240, 255, 0.28);
}
:root[data-theme="cyberpunk"] .button-outline:hover,
:root[data-theme="cyberpunk"] .button-ghost:hover {
border-color: rgba(255, 242, 0, 0.46);
background: rgba(255, 242, 0, 0.1);
}
:root[data-theme="cyberpunk"] input,
:root[data-theme="cyberpunk"] select,
:root[data-theme="cyberpunk"] textarea {
background: rgba(8, 7, 13, 0.92);
border-color: rgba(0, 240, 255, 0.34);
}
:root[data-theme="cyberpunk"] input:focus,
:root[data-theme="cyberpunk"] select:focus,
:root[data-theme="cyberpunk"] textarea:focus {
border-color: #fff200;
box-shadow: 0 0 0 3px rgba(255, 242, 0, 0.16), 0 0 22px rgba(0, 240, 255, 0.18);
}
:root[data-theme="cyberpunk"] .badge {
border: 1px solid rgba(0, 240, 255, 0.22);
background: rgba(0, 240, 255, 0.08);
color: #9bfaff;
}
:root[data-theme="cyberpunk"] .badge-active,
:root[data-theme="cyberpunk"] .storage-detail-test.is-ok > span:last-child {
color: #00ff9f;
}
:root[data-theme="cyberpunk"] .badge-disabled,
:root[data-theme="cyberpunk"] .storage-detail-test.is-err > span:last-child,
:root[data-theme="cyberpunk"] .form-error {
color: #ff2a6d;
}
:root[data-theme="cyberpunk"] .drop-zone {
border-color: rgba(255, 242, 0, 0.34);
background:
linear-gradient(145deg, rgba(255, 242, 0, 0.08), transparent 38%),
rgba(8, 7, 13, 0.76);
}
:root[data-theme="cyberpunk"] .drop-zone:hover,
:root[data-theme="cyberpunk"] .drop-zone.is-dragging {
border-color: #00f0ff;
background:
linear-gradient(145deg, rgba(0, 240, 255, 0.14), transparent 42%),
rgba(8, 7, 13, 0.82);
}
:root[data-theme="cyberpunk"] ::selection {
background: #ff2a6d;
color: #ffffff;
}
@media (prefers-reduced-motion: no-preference) {
:root[data-theme="cyberpunk"] .brand-mark,
:root[data-theme="cyberpunk"] h1 {
animation: cyberpunk-pulse 4s ease-in-out infinite;
}
}
@keyframes cyberpunk-pulse {
0%, 100% {
filter: none;
}
50% {
filter: drop-shadow(0 0 0.45rem rgba(0, 240, 255, 0.28));
}
}

View File

@@ -46,35 +46,14 @@
text-decoration: none; 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 { .thumb-link {
display: block;
overflow: hidden;
flex: 0 0 4.75rem; flex: 0 0 4.75rem;
width: 4.75rem; width: 4.75rem;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
display: block;
overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem); border-radius: calc(var(--radius) - 0.2rem);
background: var(--muted); background: var(--muted);
} }
@@ -85,58 +64,610 @@
object-fit: cover; object-fit: cover;
} }
.button.is-active {
background: var(--primary);
color: var(--primary-foreground);
}
.file-browser-window {
overflow: hidden;
margin-top: 1.25rem;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in srgb, var(--card) 94%, transparent), color-mix(in srgb, var(--background) 92%, transparent));
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.24);
text-align: left;
}
.file-browser-titlebar {
min-height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--muted) 62%, transparent);
}
.file-browser-titlebar > div:first-child {
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.file-browser-titlebar strong {
font-size: 0.95rem;
}
.file-browser-titlebar span {
color: var(--muted-foreground);
font-size: 0.78rem;
white-space: nowrap;
}
.file-browser-window-actions {
display: inline-flex;
gap: 0.35rem;
}
.file-browser-window-actions span {
width: 0.72rem;
height: 0.72rem;
border: 1px solid color-mix(in srgb, var(--border) 75%, var(--foreground));
border-radius: 999px;
background: var(--muted);
}
.file-browser-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 74%, transparent);
}
.view-toolbar {
display: inline-flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.view-toolbar .button,
.file-browser-toolbar > .button {
min-height: 2rem;
padding: 0.35rem 0.65rem;
font-size: 0.78rem;
}
.view-toolbar .icon-button {
width: 2.25rem;
padding-inline: 0;
justify-content: center;
}
.view-toolbar svg {
width: 0.95rem;
height: 0.95rem;
}
.file-browser-head {
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) minmax(8rem, 0.38fr) minmax(5rem, 0.18fr) minmax(8rem, 0.32fr);
gap: 0.75rem;
padding: 0.42rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted-foreground);
background: color-mix(in srgb, var(--background) 78%, transparent);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.file-browser-head span:first-child {
grid-column: 2;
}
.file-browser {
display: grid;
gap: 0;
padding: 0.35rem;
transition: opacity 160ms ease;
}
.file-browser .download-item {
display: grid;
min-width: 0;
border: 0;
border-radius: calc(var(--radius) - 0.25rem);
background: transparent;
box-shadow: none;
padding: 0;
transform: none;
}
.file-browser .download-item:hover {
transform: none;
}
.file-card {
position: relative;
padding: 0;
}
.file-reaction-dock {
position: static;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
max-width: 100%;
gap: 0.35rem;
pointer-events: none;
padding-right: 0.65rem;
}
.file-reactions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
gap: 0.25rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.reaction-pill {
appearance: none;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.2rem;
min-height: 1.6rem;
padding: 0.16rem 0.38rem;
border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary));
border-radius: 999px;
background: color-mix(in srgb, var(--card) 88%, #000);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 700;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
pointer-events: auto;
cursor: pointer;
}
.reaction-pill.is-hidden-summary {
display: none;
}
.reaction-pill img {
width: 1rem;
height: 1rem;
display: block;
}
.reaction-more {
appearance: none;
flex: 0 0 auto;
min-height: 1.6rem;
padding: 0.16rem 0.45rem;
border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary));
border-radius: 999px;
background: color-mix(in srgb, var(--card) 88%, #000);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 800;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24);
pointer-events: auto;
cursor: pointer;
}
.reaction-pill:hover,
.reaction-pill:focus-visible,
.reaction-more:hover,
.reaction-more:focus-visible {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-button {
width: 2.1rem;
height: 2.1rem;
display: inline-grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--card) 92%, #000);
color: var(--foreground);
opacity: 0;
transform: translateY(0.3rem) scale(0.94);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
transition: opacity 150ms ease, transform 150ms ease, border-color 150ms ease, background 150ms ease;
pointer-events: auto;
}
.reaction-button svg {
width: 1.15rem;
height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
}
.file-card:hover .reaction-button,
.file-card:focus-within .reaction-button,
.reaction-button:focus-visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.reaction-button:hover,
.reaction-button:focus-visible {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-picker {
position: fixed;
top: 0;
left: 0;
z-index: 70;
width: min(21rem, calc(100vw - 1rem));
}
html.reaction-picker-open,
html.reaction-picker-open body {
overflow: hidden;
touch-action: none;
}
.reaction-picker[hidden] {
display: none;
}
.reaction-picker.is-mobile {
inset: 0;
width: auto;
height: 100dvh;
display: grid;
place-items: end center;
overflow: hidden;
padding: 0.75rem 0.75rem max(1.5rem, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.54);
}
.reaction-picker-panel {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 97%, #000);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.52);
}
.reaction-picker.is-mobile .reaction-picker-panel {
width: min(100%, 34rem);
height: 75dvh;
max-height: 75dvh;
display: flex;
flex-direction: column;
}
.reaction-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem;
border-bottom: 1px solid var(--border);
}
.reaction-picker-close {
min-height: 2rem;
padding: 0.3rem 0.55rem;
font-size: 0.75rem;
}
.reaction-existing {
padding: 0.55rem 0.7rem 0;
}
.reaction-existing small,
.reaction-readonly-note {
display: block;
color: var(--muted-foreground);
font-size: 0.74rem;
font-weight: 700;
}
.reaction-existing-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.4rem;
}
.reaction-readonly-note {
margin: 0;
padding: 0.55rem 0.7rem 0.7rem;
}
.reaction-picker-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
padding: 0.55rem 0.7rem 0;
}
.reaction-tab {
flex: 0 0 auto;
min-height: 1.8rem;
padding: 0.25rem 0.55rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--muted);
color: var(--muted-foreground);
font-size: 0.75rem;
font-weight: 700;
}
.reaction-tab.is-active {
border-color: var(--primary);
background: var(--primary);
color: var(--primary-foreground);
}
.reaction-search {
display: block;
padding: 0.55rem 0.7rem;
}
.reaction-search input {
width: 100%;
min-height: 2.15rem;
padding: 0.35rem 0.55rem;
}
.reaction-grid-wrap {
max-height: 18rem;
overflow: auto;
padding: 0 0.7rem 0.7rem;
}
.reaction-picker.is-mobile .reaction-grid-wrap {
max-height: none;
flex: 1;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.reaction-grid {
display: none;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 0.25rem;
}
.reaction-grid.is-active {
display: grid;
}
.reaction-picker.is-mobile .reaction-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.reaction-emoji {
aspect-ratio: 1;
display: grid;
place-items: center;
min-width: 0;
padding: 0.18rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.25rem);
background: transparent;
}
.reaction-emoji:hover,
.reaction-emoji:focus-visible {
border-color: var(--border);
background: var(--accent);
}
.reaction-emoji[hidden] {
display: none;
}
.reaction-emoji img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
/* A file row behaves like an entry in a desktop file explorer: a small
thumbnail/icon followed by the name and metadata. The whole row is the click
target (raw view of the file). */
.file-open {
min-width: 0;
flex: 1;
display: grid;
grid-template-columns: 3rem minmax(0, 1fr) minmax(8rem, 0.38fr) minmax(5rem, 0.18fr);
align-items: center;
gap: 0.75rem;
color: var(--foreground);
text-decoration: none;
padding: 0.55rem 0.65rem;
border-radius: calc(var(--radius) - 0.25rem);
}
.file-open:hover,
.file-open:focus-visible {
background: var(--surface-1-hover);
}
.file-media {
flex: 0 0 3rem;
width: 3rem;
height: 3rem;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--muted);
}
.file-thumb {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.file-icon {
width: 2.1rem;
height: 2.1rem;
display: block;
object-fit: contain;
}
/* Retro (Win98) icons are tiny pixel art — keep them crisp and swap them in
only when the retro theme is active. */
.file-icon-retro {
display: none;
image-rendering: pixelated;
}
[data-theme="retro"] .file-icon-standard {
display: none;
}
[data-theme="retro"] .file-icon-retro {
display: block;
}
.file-main { .file-main {
min-width: 0; min-width: 0;
max-width: 100%; max-width: 100%;
flex: 1;
color: var(--foreground); color: var(--foreground);
text-decoration: none; text-decoration: none;
} }
.file-actions { .file-name {
display: inline-flex; min-width: 0;
align-items: center;
gap: 0.5rem;
} }
.preview-action [hidden] { .file-main small {
display: none; display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-type,
.file-size {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
text-align: right;
} }
.file-browser.is-thumbs { .file-browser.is-thumbs {
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 0.75rem;
padding: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(8.75rem, 1fr));
}
.file-browser-window.is-icon-view .file-browser-head {
display: none;
} }
.file-browser.is-thumbs .file-card { .file-browser.is-thumbs .file-card {
display: grid; display: grid;
min-height: 13.75rem;
min-width: 0; min-width: 0;
align-content: start; align-content: start;
gap: 0.7rem; gap: 0.5rem;
}
.file-browser.is-list .file-card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.32fr);
align-items: center;
min-height: 4.25rem;
cursor: pointer;
}
.file-browser.is-list .file-card:hover,
.file-browser.is-list .file-card:focus-within {
background: var(--surface-1-hover);
}
.file-browser.is-list .file-card:hover .file-open,
.file-browser.is-list .file-card:focus-within .file-open {
background: transparent;
}
.file-browser.is-thumbs .file-open {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 6.75rem auto;
gap: 1rem;
height: 100%;
min-height: 0;
padding: 0.65rem 0.65rem 3.05rem;
text-align: center;
justify-items: center;
align-content: start;
overflow: hidden;
}
.file-browser.is-thumbs .file-media {
width: min(6.75rem, 76%);
height: 6.75rem;
flex-basis: auto;
aspect-ratio: 1;
}
.file-browser.is-thumbs .file-icon {
width: 64%;
height: 64%;
} }
.file-browser.is-thumbs .file-main { .file-browser.is-thumbs .file-main {
width: 100%; width: 100%;
grid-template-columns: 1fr;
gap: 0.25rem;
align-self: start;
padding-top: 0.25rem;
} }
.file-browser.is-thumbs .thumb-link { .file-browser.is-thumbs .file-type,
width: 100%; .file-browser.is-thumbs .file-size {
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; display: none;
} }
.file-browser.is-thumbs .file-reaction-dock {
position: absolute;
right: 0.6rem;
bottom: 0.65rem;
max-width: calc(100% - 1.2rem);
padding-right: 0;
}
.context-menu { .context-menu {
position: fixed; position: fixed;
z-index: 30; z-index: 30;

View File

@@ -36,6 +36,22 @@
font-size: 1rem; font-size: 1rem;
} }
.docs-card h3 {
margin: 1.35rem 0 0;
font-size: 0.9rem;
font-weight: 650;
color: var(--foreground);
}
/* Highlights where the API token goes in the ShareX config snippet. */
.sxcu-highlight {
background: #fde047;
color: #1a1a1a;
font-weight: 700;
padding: 0 0.2rem;
border-radius: 3px;
}
.docs-card p { .docs-card p {
margin: 0.65rem 0 0; margin: 0.65rem 0 0;
color: var(--muted-foreground); color: var(--muted-foreground);

View File

@@ -1,11 +1,19 @@
.admin-header, .admin-header,
.table-header { .table-header {
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
.admin-header > *,
.table-header > *,
.admin-grid-two > *,
.logs-filter-card > * {
min-width: 0;
}
.kicker { .kicker {
margin: 0 0 0.4rem; margin: 0 0 0.4rem;
color: var(--muted-foreground); color: var(--muted-foreground);
@@ -54,7 +62,8 @@
white-space: nowrap; white-space: nowrap;
} }
.user-edit-metrics { .user-edit-metrics,
.metric-grid-4 {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@@ -72,12 +81,15 @@
} }
.admin-table-wrap { .admin-table-wrap {
max-width: 100%;
overflow-x: auto; overflow-x: auto;
margin-top: 1rem; margin-top: 1rem;
-webkit-overflow-scrolling: touch;
} }
.admin-table { .admin-table {
width: 100%; width: 100%;
min-width: 46rem;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -95,6 +107,204 @@
font-weight: 650; font-weight: 650;
} }
.sort-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--muted-foreground);
font-weight: 650;
text-decoration: none;
}
.sort-link:hover,
.sort-link.is-sorted {
color: var(--foreground);
}
.sort-arrow {
font-size: 0.7rem;
}
.pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
}
.pagination-summary {
margin: 0.6rem 0 0;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.pagination-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1rem;
}
.pagination-bar .pagination {
margin-top: 0;
}
.per-page-control {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.per-page-control select {
width: auto;
min-width: 4.5rem;
min-height: 2rem;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.button.is-disabled {
pointer-events: none;
opacity: 0.45;
}
/* Overview charts */
.admin-charts {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
.chart-card {
min-width: 0;
}
.chart-card h2 {
margin: 0;
font-size: 1.05rem;
}
.chart-card .muted-copy {
margin: 0.3rem 0 0;
}
.bar-chart {
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
align-items: end;
gap: 0.4rem;
min-height: 13rem;
margin-top: 1.25rem;
padding-top: 0.5rem;
}
.bar-chart-col {
display: flex;
flex-direction: column;
min-width: 0;
align-items: stretch;
gap: 0.35rem;
}
.bar-chart-track {
display: flex;
align-items: flex-end;
justify-content: center;
flex: 1 1 auto;
width: 100%;
max-width: 1.8rem;
height: 150px;
margin: 0 auto;
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
border-radius: 0.45rem 0.45rem 0 0;
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--border) 55%, transparent));
overflow: hidden;
}
.bar-chart-bar {
display: block;
width: 100%;
min-height: 0;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
}
.bar-chart-value {
min-height: 1rem;
overflow: hidden;
color: var(--foreground);
font-size: 0.72rem;
font-weight: 650;
line-height: 1;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-chart-label {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.66rem;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.stat-bars {
display: grid;
gap: 0.9rem;
margin-top: 1.25rem;
}
.stat-bar span {
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
font-size: 0.78rem;
}
.stat-bar span strong {
color: var(--foreground);
}
.stat-bar-track {
display: block;
width: 100%;
margin-top: 0.35rem;
height: 0.55rem;
border-radius: 999px;
background: var(--border);
overflow: hidden;
}
.stat-bar-fill {
display: block;
height: 100%;
min-width: 0;
border-radius: 999px;
background: var(--primary, #8b5cf6);
}
@media (max-width: 900px) {
.admin-charts {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.metric-grid-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.table-actions { .table-actions {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -106,6 +316,75 @@
margin: 0; margin: 0;
} }
.logs-filter-card {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.7rem;
align-items: end;
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
}
.logs-filter-card label {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.logs-filter-card label span {
color: var(--muted-foreground);
font-size: 0.72rem;
}
.logs-table td {
vertical-align: top;
}
.logs-table code {
white-space: pre-wrap;
word-break: break-word;
}
.log-time {
white-space: nowrap;
}
.admin-grid-two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.compact-form {
display: grid;
gap: 0.75rem;
}
.compact-form textarea {
width: 100%;
resize: vertical;
}
@media (max-width: 980px) {
.admin-grid-two {
grid-template-columns: 1fr;
}
.logs-filter-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.logs-filter-card {
grid-template-columns: 1fr;
}
}
/* Inline row edit (details/summary in table cells) */ /* Inline row edit (details/summary in table cells) */
.row-edit { .row-edit {
@@ -135,6 +414,7 @@
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
align-items: center; align-items: center;
flex-wrap: wrap;
margin-top: 0.4rem; margin-top: 0.4rem;
} }

View File

@@ -23,6 +23,7 @@
.storage-card-header { .storage-card-header {
display: flex; display: flex;
min-width: 0;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
@@ -56,6 +57,10 @@
.storage-card-name { .storage-card-name {
display: block; display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 650; font-weight: 650;
color: var(--foreground); color: var(--foreground);
@@ -82,9 +87,15 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.storage-card-actions form {
min-width: 0;
margin: 0;
}
/* View-mode summary */ /* View-mode summary */
.storage-card-summary { .storage-card-summary {
display: flex; display: flex;
min-width: 0;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0 1.75rem; gap: 0 1.75rem;
padding: 0.65rem 1.1rem 0.9rem; padding: 0.65rem 1.1rem 0.9rem;
@@ -96,6 +107,7 @@
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.15rem;
min-width: 8rem; min-width: 8rem;
max-width: 100%;
} }
.storage-detail > span:first-child, .storage-detail > span:first-child,
@@ -137,6 +149,14 @@
align-items: end; align-items: end;
} }
.storage-card-fields > *,
.storage-ops-grid > *,
.storage-result-row,
.storage-result-row summary > *,
.storage-result-detail > * {
min-width: 0;
}
.storage-card-fields label { .storage-card-fields label {
display: grid; display: grid;
gap: 0.28rem; gap: 0.28rem;

View File

@@ -1,12 +1,34 @@
@media (max-width: 720px) { @media (max-width: 720px) {
.nav-links { .nav {
display: inline-flex; width: min(72rem, calc(100% - 1rem));
min-height: auto;
padding: 0.55rem 0;
align-items: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; gap: 0.55rem;
}
.brand {
flex: 1 1 auto;
}
.nav-links {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: stretch;
gap: 0.4rem;
}
.nav-links .button {
flex: 1 1 auto;
min-width: 0;
padding-inline: 0.55rem;
} }
.upload-view, .upload-view,
.download-view { .download-view {
width: min(100%, calc(100% - 1rem));
min-height: auto; min-height: auto;
padding: 2rem 0; padding: 2rem 0;
} }
@@ -37,6 +59,23 @@
.app-sidebar { .app-sidebar {
position: static; position: static;
width: 100%;
overflow: hidden;
}
.sidebar-nav {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem;
}
.sidebar-link {
justify-content: flex-start;
padding-inline: 0.65rem;
}
.sidebar-logout .button {
justify-content: center;
} }
.endpoint-list div { .endpoint-list div {
@@ -56,6 +95,41 @@
flex: 1; flex: 1;
} }
.file-browser-toolbar {
align-items: stretch;
}
.file-browser-toolbar,
.file-browser-toolbar .view-toolbar {
width: 100%;
}
.file-browser-toolbar .view-toolbar .button,
.file-browser-toolbar > .button {
flex: 1 1 auto;
justify-content: center;
}
.file-browser-toolbar .view-toolbar .icon-button {
flex: 0 0 2.5rem;
}
.file-browser-head {
display: none;
}
.file-open {
grid-template-columns: 3rem minmax(0, 1fr) auto;
}
.file-type {
display: none;
}
.file-browser.is-list .file-card {
grid-template-columns: minmax(0, 1fr) minmax(7rem, auto);
}
h1 { h1 {
font-size: 1.65rem; font-size: 1.65rem;
} }
@@ -86,9 +160,59 @@
.new-collection-body { .new-collection-body {
position: static; position: static;
width: 100%; width: 100%;
max-width: 100%;
margin-top: 0.5rem; margin-top: 0.5rem;
box-shadow: none; box-shadow: none;
} }
.inline-controls {
align-items: stretch;
}
.inline-controls label,
.inline-controls input,
.inline-controls select,
.compact-input {
width: 100%;
min-width: 0;
}
.copy-field,
.token-reveal-row,
.storage-card-edit-bar {
flex-wrap: wrap;
}
.copy-field .button,
.token-reveal-row .button,
.storage-card-edit-bar .button {
flex: 1 1 auto;
}
.storage-card-header,
.storage-card-actions {
align-items: stretch;
}
.storage-card-header {
flex-direction: column;
}
.storage-card-actions,
.storage-card-actions form,
.storage-card-actions .button,
.storage-card-actions button {
width: 100%;
}
.storage-card-summary {
gap: 0.65rem;
}
.storage-detail {
min-width: 0;
width: 100%;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -96,3 +220,119 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@media (max-width: 520px) {
.app-shell {
width: min(100%, calc(100% - 1rem));
padding: 1rem 0;
gap: 1rem;
}
.card-content {
padding: 1rem;
}
.metric-grid,
.user-edit-metrics {
grid-template-columns: 1fr;
}
.storage-type-grid,
.storage-ops-grid {
grid-template-columns: 1fr;
}
.result-item,
.download-item {
align-items: stretch;
flex-wrap: wrap;
}
.file-browser-titlebar {
align-items: flex-start;
}
.file-browser-titlebar > div:first-child {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.file-browser {
padding: 0.25rem;
}
.file-open {
grid-template-columns: 2.65rem minmax(0, 1fr);
gap: 0.55rem;
padding: 0.5rem;
}
.file-media {
width: 2.65rem;
height: 2.65rem;
}
.file-size {
display: none;
}
.file-browser.is-list .file-card {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.file-browser.is-list .file-reaction-dock {
justify-content: flex-end;
padding: 0 0.5rem 0.5rem;
}
.file-browser.is-thumbs {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0.5rem;
}
.file-browser.is-thumbs .file-open {
height: 100%;
}
.file-actions,
.file-browser.is-thumbs .file-actions {
width: 100%;
display: grid;
grid-template-columns: 1fr;
}
.file-reaction-dock {
right: 0.5rem;
bottom: 0.45rem;
}
.reaction-button {
opacity: 1;
transform: none;
}
.file-progress-side {
width: 100%;
}
.site-footer {
width: min(100%, calc(100% - 1rem));
}
}
@media (max-width: 380px) {
.sidebar-nav {
grid-template-columns: 1fr;
}
.badge-row .badge {
flex: 1 1 100%;
justify-content: center;
}
.nav-links .button {
flex-basis: 100%;
}
}

View File

@@ -0,0 +1,112 @@
{
"_comment": "Maps a file's type (resolved from its extension / content type) to a file-type icon. 'standard' icons live in file-icons/standard, 'retro' (Win98) icons in file-icons/retro. The server reads this at startup and picks the icon per file; thumbnails always win over icons when present.",
"default": {
"mime": "application/octet-stream",
"standard": "txt-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png"
},
"types": [
{
"mime": "image/*",
"standard": "image-document-svgrepo-com.svg",
"retro": "shimgvw.dll_14_1-2.png",
"extensions": ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tif", "tiff", "heic", "heif", "avif", "jfif"]
},
{
"mime": "image/vnd.adobe.photoshop",
"standard": "psd-document-svgrepo-com.svg",
"retro": "shimgvw.dll_14_1-2.png",
"extensions": ["psd"]
},
{
"mime": "audio/*",
"standard": "audio-document-svgrepo-com.svg",
"retro": "wmploc.dll_14_610-2.png",
"extensions": ["mp3", "wav", "flac", "aac", "ogg", "oga", "m4a", "wma", "opus", "aiff", "aif", "mid", "midi"]
},
{
"mime": "video/mp4",
"standard": "mp4-document-svgrepo-com.svg",
"retro": "wmploc.dll_14_504-2.png",
"extensions": ["mp4", "m4v"]
},
{
"mime": "video/*",
"standard": "video-document-svgrepo-com.svg",
"retro": "wmploc.dll_14_504-2.png",
"extensions": ["mkv", "mov", "avi", "webm", "wmv", "flv", "mpg", "mpeg", "3gp", "ogv", "ts", "m2ts"]
},
{
"mime": "application/zip",
"standard": "zip-document-svgrepo-com.svg",
"retro": "zipfldr.dll_14_101-2.png",
"extensions": ["zip", "rar", "7z", "gz", "tar", "bz2", "xz", "tgz", "zst", "lz", "lzma", "cab", "iso"]
},
{
"mime": "application/pdf",
"standard": "pdf-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png",
"extensions": ["pdf"]
},
{
"mime": "text/html",
"standard": "html-document-svgrepo-com.svg",
"retro": "mshtml.dll_14_2660-2.png",
"extensions": ["html", "htm", "xhtml", "mhtml"]
},
{
"mime": "application/x-shockwave-flash",
"standard": "flash-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png",
"extensions": ["swf", "fla"]
},
{
"mime": "application/vnd.ms-excel",
"standard": "excel-document-svgrepo-com.svg",
"retro": "shell32.dll_14_151-2.png",
"extensions": ["xls", "xlsx", "xlsm", "ods"]
},
{
"mime": "text/csv",
"standard": "csv-document-svgrepo-com.svg",
"retro": "shell32.dll_14_151-2.png",
"extensions": ["csv", "tsv"]
},
{
"mime": "application/msword",
"standard": "word-document-svgrepo-com.svg",
"retro": "shell32.dll_14_2-0.png",
"extensions": ["doc", "docx", "odt"]
},
{
"mime": "application/rtf",
"standard": "rtf-document-svgrepo-com.svg",
"retro": "shell32.dll_14_2-0.png",
"extensions": ["rtf"]
},
{
"mime": "application/vnd.apple.pages",
"standard": "pages-document-svgrepo-com.svg",
"retro": "shell32.dll_14_2-0.png",
"extensions": ["pages"]
},
{
"mime": "application/vnd.visio",
"standard": "visio-document-svgrepo-com.svg",
"retro": "shell32.dll_14_152-2.png",
"extensions": ["vsd", "vsdx"]
},
{
"mime": "application/x-msdownload",
"standard": "exe-document-svgrepo-com.svg",
"retro": "shell32.dll_14_3-0.png",
"extensions": ["exe", "msi", "bat", "cmd", "com", "app", "dmg", "apk", "deb", "rpm", "appimage"]
},
{
"mime": "text/plain",
"standard": "txt-document-svgrepo-com.svg",
"retro": "shell32.dll_14_151-2.png",
"extensions": ["txt", "text", "log", "md", "markdown", "ini", "cfg", "conf", "json", "xml", "yaml", "yml", "toml", "js", "ts", "jsx", "tsx", "go", "py", "rb", "php", "java", "c", "h", "cpp", "cc", "cs", "rs", "sh", "bash", "css", "scss", "sql"]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.151.012c-2.802 0-5.073 2.272-5.073 5.073v53.842c0 2.802 2.272 5.073 5.073 5.073h45.774c2.803 0 5.075-2.271 5.075-5.073v-38.606l-18.903-20.309h-31.946z" fill="#379FD3"/>
<path d="M56 20.357v1h-12.8s-6.312-1.26-6.128-6.707c0 0 .208 5.707 6.003 5.707h12.925z" fill="#2987C8"/>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.106 0c-2.802 0-5.073 2.272-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#45B058"/>
<path d="M20.306 43.197c.126.144.198.324.198.522 0 .378-.306.72-.703.72-.18 0-.378-.072-.504-.234-.702-.846-1.891-1.387-3.007-1.387-2.629 0-4.627 2.017-4.627 4.88 0 2.845 1.999 4.879 4.627 4.879 1.134 0 2.25-.486 3.007-1.369.125-.144.324-.233.504-.233.415 0 .703.359.703.738 0 .18-.072.36-.198.504-.937.972-2.215 1.693-4.015 1.693-3.457 0-6.176-2.521-6.176-6.212s2.719-6.212 6.176-6.212c1.8.001 3.096.721 4.015 1.711zm6.802 10.714c-1.782 0-3.187-.594-4.213-1.495-.162-.144-.234-.342-.234-.54 0-.361.27-.757.702-.757.144 0 .306.036.432.144.828.739 1.98 1.314 3.367 1.314 2.143 0 2.827-1.152 2.827-2.071 0-3.097-7.112-1.386-7.112-5.672 0-1.98 1.764-3.331 4.123-3.331 1.548 0 2.881.467 3.853 1.278.162.144.252.342.252.54 0 .36-.306.72-.703.72-.144 0-.306-.054-.432-.162-.882-.72-1.98-1.044-3.079-1.044-1.44 0-2.467.774-2.467 1.909 0 2.701 7.112 1.152 7.112 5.636.001 1.748-1.187 3.531-4.428 3.531zm16.994-11.254l-4.159 10.335c-.198.486-.685.81-1.188.81h-.036c-.522 0-1.008-.324-1.207-.81l-4.142-10.335c-.036-.09-.054-.18-.054-.288 0-.36.323-.793.81-.793.306 0 .594.18.72.486l3.889 9.992 3.889-9.992c.108-.288.396-.486.72-.486.468 0 .81.378.81.793.001.09-.017.198-.052.288z" fill="#ffffff"/>
<g fill-rule="evenodd" clip-rule="evenodd">

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg"><path d="M5.112.006c-2.802 0-5.073 2.273-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#45B058"/><path d="M19.429 53.938c-.216 0-.415-.09-.54-.27l-3.728-4.97-3.745 4.97c-.126.18-.324.27-.54.27-.396 0-.72-.306-.72-.72 0-.144.035-.306.144-.432l3.89-5.131-3.619-4.826c-.09-.126-.145-.27-.145-.414 0-.342.288-.72.721-.72.216 0 .432.108.576.288l3.438 4.628 3.438-4.646c.127-.18.324-.27.541-.27.378 0 .738.306.738.72 0 .144-.036.288-.127.414l-3.619 4.808 3.891 5.149c.09.126.125.27.125.414 0 .396-.324.738-.719.738zm9.989-.126h-5.455c-.595 0-1.081-.486-1.081-1.08v-10.317c0-.396.324-.72.774-.72.396 0 .721.324.721.72v10.065h5.041c.359 0 .648.288.648.648 0 .396-.289.684-.648.684zm6.982.216c-1.782 0-3.188-.594-4.213-1.495-.162-.144-.234-.342-.234-.54 0-.36.27-.756.702-.756.144 0 .306.036.433.144.828.738 1.98 1.314 3.367 1.314 2.143 0 2.826-1.152 2.826-2.071 0-3.097-7.111-1.386-7.111-5.672 0-1.98 1.764-3.331 4.123-3.331 1.548 0 2.881.468 3.853 1.278.162.144.253.342.253.54 0 .36-.307.72-.703.72-.145 0-.307-.054-.432-.162-.883-.72-1.98-1.044-3.079-1.044-1.44 0-2.467.774-2.467 1.909 0 2.701 7.112 1.152 7.112 5.636 0 1.748-1.188 3.53-4.43 3.53z" fill="#ffffff"/><path d="M55.953 20.352v1h-12.801s-6.312-1.26-6.127-6.707c0 0 .207 5.707 6.002 5.707h12.926z" fill-rule="evenodd" clip-rule="evenodd" fill="#349C42"/><path d="M37.049 0v14.561c0 1.656 1.104 5.791 6.104 5.791h12.801l-18.905-20.352z" opacity=".5" fill-rule="evenodd" clip-rule="evenodd" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.112.025c-2.802 0-5.073 2.272-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8199AF"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.961 20.377v1h-12.799s-6.312-1.26-6.129-6.708c0 0 .208 5.708 6.004 5.708h12.924z" fill="#617F9B"/>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.112.009c-2.802 0-5.073 2.273-5.073 5.074v53.841c0 2.803 2.271 5.074 5.073 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.904-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#E53C3C"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.961 20.346v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#DE2D2D"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.135.008c-2.803 0-5.074 2.272-5.074 5.074v53.84c0 2.803 2.271 5.074 5.074 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#F7622C"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.976 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#F54921"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.125.042c-2.801 0-5.072 2.273-5.072 5.074v53.841c0 2.803 2.271 5.073 5.072 5.073h45.775c2.801 0 5.074-2.271 5.074-5.073v-38.604l-18.904-20.311h-31.945z" fill="#49C9A7"/>
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#37BB91"/>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M5.116.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.946z" fill="#9B64B2"/>
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#824B9E"/>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.111-.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#6A6AE2"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.976 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#4F4FDA"/>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
width="800px" height="800px" viewBox="0 0 56 64" enable-background="new 0 0 56 64" xml:space="preserve">
<g>
<path fill="#8C181A" d="M5.1,0C2.3,0,0,2.3,0,5.1v53.8C0,61.7,2.3,64,5.1,64h45.8c2.8,0,5.1-2.3,5.1-5.1V20.3L37.1,0H5.1z"/>
<path fill="#6B0D12" d="M56,20.4v1H43.2c0,0-6.3-1.3-6.1-6.7c0,0,0.2,5.7,6,5.7H56z"/>
<path opacity="0.5" fill="#FFFFFF" enable-background="new " d="M37.1,0v14.6c0,1.7,1.1,5.8,6.1,5.8H56L37.1,0z"/>
</g>
<path fill="#FFFFFF" d="M14.9,49h-3.3v4.1c0,0.4-0.3,0.7-0.8,0.7c-0.4,0-0.7-0.3-0.7-0.7V42.9c0-0.6,0.5-1.1,1.1-1.1h3.7
c2.4,0,3.8,1.7,3.8,3.6C18.7,47.4,17.3,49,14.9,49z M14.8,43.1h-3.2v4.6h3.2c1.4,0,2.4-0.9,2.4-2.3C17.2,44,16.2,43.1,14.8,43.1z
M25.2,53.8h-3c-0.6,0-1.1-0.5-1.1-1.1v-9.8c0-0.6,0.5-1.1,1.1-1.1h3c3.7,0,6.2,2.6,6.2,6C31.4,51.2,29,53.8,25.2,53.8z M25.2,43.1
h-2.6v9.3h2.6c2.9,0,4.6-2.1,4.6-4.7C29.9,45.2,28.2,43.1,25.2,43.1z M41.5,43.1h-5.8V47h5.7c0.4,0,0.6,0.3,0.6,0.7
s-0.3,0.6-0.6,0.6h-5.7v4.8c0,0.4-0.3,0.7-0.8,0.7c-0.4,0-0.7-0.3-0.7-0.7V42.9c0-0.6,0.5-1.1,1.1-1.1h6.2c0.4,0,0.6,0.3,0.6,0.7
C42.2,42.8,41.9,43.1,41.5,43.1z"/>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.112.051c-2.802 0-5.073 2.273-5.073 5.075v53.841c0 2.802 2.271 5.073 5.073 5.073h45.775c2.801 0 5.074-2.271 5.074-5.073v-38.606l-18.903-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#0C77C6"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#0959B7"/>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.113.006c-2.803 0-5.074 2.273-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#00A1EE"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#0089E9"/>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.151-.036c-2.803 0-5.074 2.272-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#F9CA06"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M56.008 20.316v1h-12.799s-6.312-1.26-6.129-6.708c0 0 .208 5.708 6.004 5.708h12.924z" fill="#F7BC04"/>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.15.011c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.272 5.074 5.072 5.074h45.775c2.802 0 5.075-2.271 5.075-5.074v-38.606l-18.904-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8E4C9E"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#713985"/>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.111.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.606l-18.903-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#496AB3"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#374FA0"/>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill-rule="evenodd">
<path d="m5.11 0a5.07 5.07 0 0 0 -5.11 5v53.88a5.07 5.07 0 0 0 5.11 5.12h45.78a5.07 5.07 0 0 0 5.11-5.12v-38.6l-18.94-20.28z" fill="#107cad"/>
<path d="m56 20.35v1h-12.82s-6.31-1.26-6.13-6.71c0 0 .21 5.71 6 5.71z" fill="#084968"/>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M5.113-.026c-2.803 0-5.074 2.272-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.773c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.901-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8199AF"/>
<g fill-rule="evenodd" clip-rule="evenodd">
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#617F9B"/>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -11,7 +11,7 @@
*/ */
(function () { (function () {
var STORAGE_KEY = "warpbox-theme"; var STORAGE_KEY = "warpbox-theme";
var THEMES = ["revamp", "classic", "retro"]; var THEMES = ["revamp", "classic", "retro", "gruvbox", "cyberpunk"];
function stored() { function stored() {
try { try {

View File

@@ -1,33 +1,50 @@
(function () { (function () {
const fileBrowser = document.querySelector("[data-file-browser]"); const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]"); const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
const previewActions = document.querySelectorAll("[data-preview-action]"); const previewActions = document.querySelectorAll("[data-preview-action]");
const fileContextMenu = document.querySelector("[data-file-context-menu]"); const fileContextMenu = document.querySelector("[data-file-context-menu]");
const fileBrowserWindow = document.querySelector("[data-file-browser-window]");
let ctrlCopyMode = false; let ctrlCopyMode = false;
let contextFile = null; let contextFile = null;
const contextMenuCloseDistance = 80; const contextMenuCloseDistance = 80;
const viewStorageKey = "warpbox.fileBrowser.view";
if (fileBrowser) { if (fileBrowser) {
applySavedFileBrowserPreferences();
viewButtons.forEach((button) => { viewButtons.forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const view = button.getAttribute("data-view-button"); const view = button.getAttribute("data-view-button");
fileBrowser.classList.toggle("is-list", view === "list"); setFileBrowserView(view);
fileBrowser.classList.toggle("is-thumbs", view === "thumbs"); savePreference(viewStorageKey, view);
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
}); });
}); });
if (previewImages) {
previewImages.addEventListener("click", () => {
fileBrowser.classList.toggle("images-only");
previewImages.classList.toggle("is-active");
});
}
} }
if (fileBrowser && fileContextMenu) { if (fileBrowser && fileContextMenu) {
document.body.appendChild(fileContextMenu);
fileBrowser.addEventListener("click", (event) => {
if (!fileBrowser.classList.contains("is-list")) {
return;
}
if (event.target.closest("a, button, input, select, textarea")) {
return;
}
const card = event.target.closest("[data-file-context]");
const link = card ? card.querySelector(".file-open") : null;
if (!link) {
return;
}
event.preventDefault();
if (link.target === "_blank") {
window.Warpbox.openInNewTab(link.href);
return;
}
window.location.href = link.href;
});
fileBrowser.addEventListener("contextmenu", (event) => { fileBrowser.addEventListener("contextmenu", (event) => {
const card = event.target.closest("[data-file-context]"); const card = event.target.closest("[data-file-context]");
if (!card) { if (!card) {
@@ -188,4 +205,40 @@
y >= rect.top - contextMenuCloseDistance && y >= rect.top - contextMenuCloseDistance &&
y <= rect.bottom + contextMenuCloseDistance; y <= rect.bottom + contextMenuCloseDistance;
} }
function applySavedFileBrowserPreferences() {
const savedView = readPreference(viewStorageKey);
setFileBrowserView(savedView === "list" ? "list" : "thumbs");
}
function setFileBrowserView(view) {
const normalized = view === "thumbs" ? "thumbs" : "list";
fileBrowser.classList.toggle("is-list", normalized === "list");
fileBrowser.classList.toggle("is-thumbs", normalized === "thumbs");
if (fileBrowserWindow) {
fileBrowserWindow.classList.toggle("is-list-view", normalized === "list");
fileBrowserWindow.classList.toggle("is-icon-view", normalized === "thumbs");
}
viewButtons.forEach((item) => {
const active = item.getAttribute("data-view-button") === normalized;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function readPreference(key) {
try {
return window.localStorage.getItem(key);
} catch (_) {
return "";
}
}
function savePreference(key, value) {
try {
window.localStorage.setItem(key, value);
} catch (_) {
// LocalStorage can be unavailable in private or locked-down browsers.
}
}
})(); })();

View File

@@ -0,0 +1,304 @@
(function () {
const picker = document.querySelector("[data-reaction-picker]");
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
const existingSection = picker ? picker.querySelector("[data-reaction-existing]") : null;
const existingList = picker ? picker.querySelector("[data-reaction-existing-list]") : null;
const readonlyNote = picker ? picker.querySelector("[data-reaction-readonly]") : null;
const chooserElements = picker ? Array.from(picker.querySelectorAll(".reaction-picker-tabs, .reaction-search, .reaction-grid-wrap")) : [];
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
let activeButton = null;
let activeCard = null;
document.querySelectorAll("[data-reaction-button]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
openPicker(button);
});
});
document.addEventListener("click", (event) => {
const pill = event.target.closest("[data-reaction-pill]");
if (pill) {
event.preventDefault();
event.stopPropagation();
const card = pill.closest("[data-reaction-card]") || activeCard;
if (!card) {
return;
}
if (card.dataset.reacted === "true") {
openPickerForCard(card, pill);
return;
}
submitReactionForCard(card, pill.dataset.reactionEmojiId);
return;
}
const more = event.target.closest("[data-reaction-more]");
if (!more) {
return;
}
event.preventDefault();
event.stopPropagation();
const card = more.closest("[data-reaction-card]");
if (card) {
openPickerForCard(card, more);
}
});
if (!picker || !panel) {
return;
}
// Aurora's glass card uses backdrop-filter, and the main content animates
// with transform. Both can create a containing block for fixed descendants,
// so keep the floating picker at body level where viewport coordinates mean
// what they say.
document.body.appendChild(picker);
picker.addEventListener("click", (event) => {
if (event.target === picker) {
closePicker();
}
});
panel.addEventListener("click", async (event) => {
const emoji = event.target.closest("[data-emoji-id]");
if (!emoji || !activeCard || activeCard.dataset.reacted === "true") {
return;
}
await submitReactionForCard(activeCard, emoji.dataset.emojiId);
});
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
setActiveTab(tab.dataset.reactionTab);
});
});
if (search) {
search.addEventListener("input", () => filterEmoji(search.value));
}
if (closeButton) {
closeButton.addEventListener("click", closePicker);
}
document.addEventListener("click", (event) => {
if (picker.hidden) {
return;
}
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
return;
}
if (event.target.closest("[data-reaction-more]") || event.target.closest("[data-reaction-pill]")) {
return;
}
closePicker();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePicker();
}
});
window.addEventListener("resize", () => {
if (activeButton && !picker.hidden) {
positionPicker(activeButton);
}
});
function openPicker(button) {
openPickerForCard(button.closest("[data-reaction-card]"), button);
}
function openPickerForCard(card, trigger) {
if (!card) {
return;
}
activeButton = trigger || card.querySelector("[data-reaction-button]");
activeCard = card;
populateExistingReactions(card);
setPickerReadonly(card.dataset.reacted === "true");
picker.hidden = false;
picker.classList.add("is-open");
if (search) {
search.value = "";
filterEmoji("");
}
positionPicker(activeButton || card);
}
function closePicker() {
picker.hidden = true;
picker.classList.remove("is-open", "is-mobile");
document.documentElement.classList.remove("reaction-picker-open");
picker.style.left = "";
picker.style.top = "";
setPickerReadonly(false);
activeButton = null;
activeCard = null;
}
function positionPicker(button) {
if (isMobilePicker()) {
picker.classList.add("is-mobile");
document.documentElement.classList.add("reaction-picker-open");
picker.style.left = "0px";
picker.style.top = "0px";
return;
}
picker.classList.remove("is-mobile");
document.documentElement.classList.remove("reaction-picker-open");
picker.style.left = "0px";
picker.style.top = "0px";
const buttonRect = button.getBoundingClientRect();
const pickerRect = panel.getBoundingClientRect();
const margin = 10;
const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2);
const preferredTop = buttonRect.bottom + 8;
const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin);
const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin);
picker.style.left = `${left}px`;
picker.style.top = `${top}px`;
}
function isMobilePicker() {
return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches;
}
function setActiveTab(tabID) {
tabs.forEach((tab) => {
const active = tab.dataset.reactionTab === tabID;
tab.classList.toggle("is-active", active);
tab.setAttribute("aria-selected", active ? "true" : "false");
});
panels.forEach((item) => {
item.classList.toggle("is-active", item.dataset.reactionPanel === tabID);
});
}
function filterEmoji(value) {
const query = value.trim().toLowerCase();
picker.querySelectorAll("[data-emoji-id]").forEach((button) => {
const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase();
button.hidden = query !== "" && !haystack.includes(query);
});
}
async function submitReactionForCard(card, emojiID) {
if (!card || !emojiID || card.dataset.reacted === "true") {
return;
}
const body = new URLSearchParams();
body.set("emoji_id", emojiID);
const reactButton = card.querySelector("[data-reaction-button]");
if (reactButton) {
reactButton.disabled = true;
}
const response = await fetch(card.dataset.reactUrl, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
if (reactButton) {
reactButton.disabled = false;
}
closePicker();
return;
}
const payload = await response.json();
renderReactions(card, payload.reactions || []);
card.dataset.reacted = "true";
if (reactButton) {
reactButton.remove();
}
closePicker();
}
function renderReactions(card, reactions) {
const list = card.querySelector("[data-reaction-list]");
if (!list) {
return;
}
list.replaceChildren();
reactions.forEach((reaction) => {
const pill = buildReactionPill(reaction);
if (!reaction.visible) {
pill.classList.add("is-hidden-summary");
}
list.append(pill);
});
const hiddenCount = reactions.length > 2 ? reactions.length - 2 : 0;
if (hiddenCount > 0) {
const more = document.createElement("button");
more.className = "reaction-more";
more.type = "button";
more.dataset.reactionMore = "";
more.textContent = `+${hiddenCount}`;
more.setAttribute("aria-label", `Show ${hiddenCount} more reactions`);
list.append(more);
}
}
function buildReactionPill(reaction) {
const pill = document.createElement("button");
pill.className = "reaction-pill";
pill.type = "button";
pill.title = reaction.label || reaction.emojiId;
pill.dataset.reactionPill = "";
pill.dataset.reactionEmojiId = reaction.emojiId;
pill.dataset.reactionLabel = reaction.label || reaction.emojiId;
pill.dataset.reactionUrl = reaction.url;
pill.dataset.reactionCount = reaction.count;
pill.setAttribute("aria-label", `React with ${reaction.label || reaction.emojiId}`);
const image = document.createElement("img");
image.src = reaction.url;
image.alt = reaction.label || reaction.emojiId;
image.loading = "lazy";
const count = document.createElement("span");
count.textContent = reaction.count;
pill.append(image, count);
return pill;
}
function populateExistingReactions(card) {
if (!existingSection || !existingList) {
return;
}
existingList.replaceChildren();
card.querySelectorAll("[data-reaction-pill]").forEach((pill) => {
const clone = pill.cloneNode(true);
clone.classList.remove("is-hidden-summary");
existingList.append(clone);
});
existingSection.hidden = existingList.children.length === 0;
}
function setPickerReadonly(readonly) {
picker.classList.toggle("is-readonly", readonly);
chooserElements.forEach((element) => {
element.hidden = readonly;
});
if (readonlyNote) {
readonlyNote.hidden = !readonly;
}
}
})();

View File

@@ -1,4 +1,16 @@
(function () { (function () {
document.querySelectorAll("[data-storage-delete-warning]").forEach((button) => {
button.addEventListener("click", (event) => {
const name = button.getAttribute("data-storage-delete-warning") || "this storage backend";
const confirmed = window.confirm(
`Delete ${name}?\n\nAll boxes stored on this location will also be deleted. Any global defaults or user storage overrides pointing at it will be reset back to inherited local storage.`
);
if (!confirmed) {
event.preventDefault();
}
});
});
document.querySelectorAll("[data-storage-speed-open]").forEach((button) => { document.querySelectorAll("[data-storage-speed-open]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const modal = document.querySelector("[data-storage-speed-modal]"); const modal = document.querySelector("[data-storage-speed-modal]");

View File

@@ -0,0 +1,57 @@
(function () {
const maxBarHeight = 150;
function numberAttr(element, name) {
const value = Number(element.getAttribute(name));
return Number.isFinite(value) ? value : 0;
}
function applyChartBars() {
document.querySelectorAll(".bar-chart").forEach((chart) => {
const bars = Array.from(chart.querySelectorAll(".bar-chart-col"));
const maxValue = Math.max(0, ...bars.map((bar) => numberAttr(bar, "data-chart-value")));
bars.forEach((bar) => {
const fill = bar.querySelector(".bar-chart-bar");
if (!fill) {
return;
}
const value = numberAttr(bar, "data-chart-value");
let height = numberAttr(fill, "data-height-px");
if (maxValue > 0) {
height = value <= 0 ? 0 : Math.max(8, Math.round((value / maxValue) * maxBarHeight));
}
fill.style.height = `${Math.min(maxBarHeight, height)}px`;
});
});
}
function applyStatusBars() {
const rows = Array.from(document.querySelectorAll(".stat-bar"));
const maxValue = Math.max(0, ...rows.map((row) => numberAttr(row, "data-stat-value")));
rows.forEach((row) => {
const fill = row.querySelector(".stat-bar-fill");
if (!fill) {
return;
}
const value = numberAttr(row, "data-stat-value");
let width = numberAttr(fill, "data-width-percent");
if (maxValue > 0) {
width = value <= 0 ? 0 : Math.round((value / maxValue) * 100);
}
fill.style.width = `${Math.max(0, Math.min(100, width))}%`;
});
}
function init() {
applyChartBars();
applyStatusBars();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -0,0 +1,43 @@
// Per-page selector: remembers the chosen page size in localStorage and keeps
// the URL's `per` query param in sync. CSP-safe (external file, no inline JS).
(function () {
const select = document.querySelector("[data-per-page]");
if (!select) {
return;
}
const key = "warpbox-perpage-" + select.dataset.perPage;
const url = new URL(window.location.href);
const current = url.searchParams.get("per");
let stored = null;
try {
stored = window.localStorage.getItem(key);
} catch (err) {
stored = null;
}
// No explicit choice in the URL but a remembered preference exists: apply it.
if (!current && stored && stored !== select.value) {
const valid = Array.prototype.some.call(select.options, function (opt) {
return opt.value === stored;
});
if (valid) {
url.searchParams.set("per", stored);
url.searchParams.delete("page");
window.location.replace(url.toString());
return;
}
}
select.addEventListener("change", function () {
try {
window.localStorage.setItem(key, select.value);
} catch (err) {
/* ignore storage failures (private mode, etc.) */
}
const next = new URL(window.location.href);
next.searchParams.set("per", select.value);
next.searchParams.delete("page");
window.location.assign(next.toString());
});
})();

View File

@@ -15,23 +15,28 @@
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}} {{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}} {{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
<script src="/static/js/05-theme.js"></script> <script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
<link rel="stylesheet" href="/static/css/00-base.css"> <link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/10-layout.css"> <link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/15-revamp.css"> <link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/16-retro.css"> <link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/20-upload.css"> <link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/30-download.css"> <link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/40-docs.css"> <link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/50-admin.css"> <link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/60-storage.css"> <link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/70-tokens.css"> <link rel="stylesheet" href="/static/css/50-admin.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/90-responsive.css"> <link rel="stylesheet" href="/static/css/60-storage.css?version={{.AppVersion}}">
<script defer src="/static/js/00-utils.js"></script> <link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
<script defer src="/static/js/10-file-browser.js"></script> <link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
<script defer src="/static/js/20-storage-admin.js"></script> <script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/30-token-copy.js"></script> <script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/40-upload.js"></script> <script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
</head> </head>
<body class="dark"> <body class="dark">
<a class="skip-link" href="#main">Skip to content</a> <a class="skip-link" href="#main">Skip to content</a>
@@ -60,13 +65,15 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span> <span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}}</span>
<label class="theme-picker"> <label class="theme-picker">
<span>Theme</span> <span>Theme</span>
<select data-theme-select aria-label="Site theme"> <select data-theme-select aria-label="Site theme">
<option value="revamp">Aurora (default)</option> <option value="revamp">Aurora (default)</option>
<option value="classic">Classic</option> <option value="classic">Classic</option>
<option value="retro">Web 1.0 (retro)</option> <option value="retro">Web 1.0 (retro)</option>
<option value="gruvbox">Gruvbox</option>
<option value="cyberpunk">CyberPunk</option>
</select> </select>
</label> </label>
<span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span> <span class="footer-links">{{if .CurrentUser}}<a href="/app">Dashboard</a><a href="/api">API</a><a href="/account/settings">Account</a>{{else}}<a href="/login">Sign in</a><a href="/api">API</a>{{end}}</span>

View File

@@ -9,6 +9,8 @@
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a> <a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a> <a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a> <a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -56,6 +58,55 @@
</article> </article>
</div> </div>
<div class="admin-charts">
<div class="card chart-card">
<div class="card-content">
<h2>Uploads per day</h2>
<p class="muted-copy">New boxes created over the last 14 days.</p>
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
{{range .Data.Overview.UploadDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</span>
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span>
</div>
{{end}}
</div>
</div>
</div>
<div class="card chart-card">
<div class="card-content">
<h2>Box status</h2>
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
<div class="stat-bars">
{{range .Data.Overview.StatusBars}}
<div class="stat-bar" data-stat-value="{{.RawValue}}">
<span>{{.Label}} <strong>{{.Value}}</strong></span>
<span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
</div>
{{end}}
</div>
</div>
</div>
</div>
<div class="card chart-card">
<div class="card-content">
<h2>Storage added per day</h2>
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
{{range .Data.Overview.StorageDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</span>
<span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span>
</div>
{{end}}
</div>
</div>
</div>
<div class="card admin-table-card"> <div class="card admin-table-card">
<div class="card-content"> <div class="card-content">
<div class="table-header"> <div class="table-header">

View File

@@ -0,0 +1,153 @@
{{define "admin_bans.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-bans-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link is-active" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a></nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-bans-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Manual IP/CIDR bans and optional automatic abuse protection.</p>
</div>
<a class="button button-outline" href="/admin/logs">Open logs</a>
</div>
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
<div class="metric-grid metric-grid-4">
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
<article class="metric-card"><span>Auto-ban</span><strong>{{if .Data.Bans.Settings.AutoBanEnabled}}Enabled{{else}}Off{{end}}</strong></article>
</div>
<div class="admin-grid-two">
<div class="card">
<div class="card-content">
<h2>Manual ban</h2>
<form class="settings-form compact-form" action="/admin/bans" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label><span>IP or CIDR</span><input name="target" placeholder="203.0.113.10 or 203.0.113.0/24" required></label>
<label><span>Reason</span><input name="reason" placeholder="Repeated abuse" required></label>
<label><span>Ban until</span><input type="datetime-local" name="expires_at" required></label>
<button class="button button-danger" type="submit">Ban target</button>
</form>
</div>
</div>
<div class="card">
<div class="card-content">
<h2>Auto-ban settings</h2>
<form class="settings-form compact-form" action="/admin/bans/settings" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label class="checkbox-field">
<input type="checkbox" name="auto_ban_enabled" {{if .Data.Bans.Settings.AutoBanEnabled}}checked{{end}}>
<span>Enable automatic bans</span>
</label>
<label><span>Auto-ban duration (hours)</span><input type="number" min="1" name="auto_ban_duration_hours" value="{{.Data.Bans.Settings.AutoBanDurationHours}}" required></label>
<label><span>Abuse window (hours)</span><input type="number" min="1" name="abuse_window_hours" value="{{.Data.Bans.Settings.AbuseWindowHours}}" required></label>
<label><span>Malicious path threshold</span><input type="number" min="1" name="malicious_path_threshold" value="{{.Data.Bans.Settings.MaliciousPathThreshold}}" required></label>
<label><span>Admin login failures</span><input type="number" min="1" name="admin_login_failure_threshold" value="{{.Data.Bans.Settings.AdminLoginFailureThreshold}}" required></label>
<label><span>User login failures</span><input type="number" min="1" name="user_login_failure_threshold" value="{{.Data.Bans.Settings.UserLoginFailureThreshold}}" required></label>
<button class="button button-primary" type="submit">Save auto-ban settings</button>
</form>
</div>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Ban records</h2>
<p>Active records block requests before the normal route handler runs.</p>
</div>
</div>
<form class="logs-filter-card" method="get" action="/admin/bans">
<label><span>Status</span>
<select name="status">
<option value="">All</option>
<option value="active" {{if eq .Data.Bans.Status "active"}}selected{{end}}>Active</option>
<option value="expired" {{if eq .Data.Bans.Status "expired"}}selected{{end}}>Expired</option>
<option value="unbanned" {{if eq .Data.Bans.Status "unbanned"}}selected{{end}}>Unbanned</option>
</select>
</label>
<label><span>Search</span><input name="q" value="{{.Data.Bans.Query}}" placeholder="IP, CIDR, reason"></label>
<button class="button button-outline" type="submit">Filter</button>
</form>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>Target</th>
<th>Reason</th>
<th>Source</th>
<th>Status</th>
<th>Expires</th>
<th>Last match</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Bans.Bans}}
<tr>
<td><code>{{.Target}}</code></td>
<td>{{.Reason}}</td>
<td>{{.Source}}</td>
<td><span class="badge">{{.Status}}</span></td>
<td>{{.ExpiresAt}}</td>
<td>{{.LastMatched}}</td>
<td>
{{if eq .Status "active"}}
<form action="/admin/bans/{{.ID}}/unban" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline" type="submit">Unban</button>
</form>
{{else}}
<span class="muted-copy">No action</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="7">No bans match this filter.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<h2>Malicious path rules</h2>
<p class="muted-copy">One case-insensitive substring per line. These rules only create bans when auto-ban is enabled.</p>
<form class="settings-form compact-form" action="/admin/bans/rules" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label><span>Patterns</span><textarea name="patterns" rows="10" spellcheck="false">{{.Data.Bans.RulePatterns}}</textarea></label>
<button class="button button-primary" type="submit">Save rules</button>
</form>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,131 @@
{{define "admin_box_edit.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-box-edit-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link is-active" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console · <a href="/admin/files">Files</a></p>
<h1 id="admin-box-edit-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Box <code>{{.Data.Box.ID}}</code> · {{.Data.Box.Owner}}</p>
</div>
<a class="button button-outline" href="/admin/boxes/{{.Data.Box.ID}}/view">Open box</a>
</div>
{{if .Data.Notice}}<p class="form-success">{{.Data.Notice}}</p>{{end}}
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Box settings</h2>
<p>Change expiration, download limit, and protection.</p>
</div>
</div>
<dl class="manage-details">
<div><dt>Created</dt><dd>{{.Data.Box.CreatedAt}}</dd></div>
<div><dt>Files</dt><dd>{{.Data.Box.FileCount}}</dd></div>
<div><dt>Total size</dt><dd>{{.Data.Box.TotalSize}}</dd></div>
<div><dt>Downloads</dt><dd>{{.Data.Box.DownloadCount}}{{if .Data.Box.MaxDownloads}} / {{.Data.Box.MaxDownloads}}{{end}}</dd></div>
<div><dt>Expires</dt><dd>{{.Data.Box.ExpiresLabel}}</dd></div>
<div><dt>Storage backend</dt><dd>{{.Data.Box.BackendID}}</dd></div>
<div><dt>Protected</dt><dd>{{if .Data.Box.Protected}}Yes{{else}}No{{end}}</dd></div>
</dl>
<form class="settings-form settings-form-narrow" action="/admin/boxes/{{.Data.Box.ID}}/edit" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
<span>Expires at (UTC)</span>
<input type="datetime-local" name="expires_at" value="{{.Data.Box.ExpiresInput}}">
</label>
<label class="checkbox-field">
<input type="checkbox" name="never_expires" {{if .Data.Box.NeverExpires}}checked{{end}}>
<span>Never expires (overrides the date above)</span>
</label>
<label>
<span>Max downloads (0 = unlimited)</span>
<input type="number" min="0" name="max_downloads" value="{{.Data.Box.MaxDownloads}}">
</label>
{{if .Data.Box.Protected}}
<label class="checkbox-field">
<input type="checkbox" name="remove_password">
<span>Remove password protection</span>
</label>
{{end}}
<button class="button button-primary" type="submit">Save changes</button>
</form>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Files</h2>
<p>Remove individual files from this box. Removing the last file deletes the box.</p>
</div>
</div>
<div class="result-list">
{{range .Data.Files}}
<article class="download-item">
{{if .HasPreview}}<a class="thumb-link" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer"><img src="{{.ThumbnailURL}}" alt="" loading="lazy"></a>{{end}}
<a class="file-main" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
<small>{{.Size}} · {{.ContentType}}</small>
</a>
<div class="file-actions">
<a class="button button-outline button-sm" href="{{.DownloadURL}}" download="{{.Name}}">Download</a>
<form action="/admin/boxes/{{$.Data.Box.ID}}/files/{{.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit">Remove</button>
</form>
</div>
</article>
{{else}}
<p class="muted-copy">This box has no files.</p>
{{end}}
</div>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Danger zone</h2>
<p>Permanently delete this box and all of its files.</p>
</div>
<form action="/admin/boxes/{{.Data.Box.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-danger" type="submit">Delete box</button>
</form>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,114 @@
{{define "admin_files.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-files-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link is-active" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-files-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">{{.Data.Total}} box{{if ne .Data.Total 1}}es{{end}} total.</p>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>All uploads</h2>
<p>Search, sort, and manage every box.</p>
</div>
<form class="inline-controls" method="get" action="/admin/files">
<input type="hidden" name="sort" value="{{.Data.Sort}}">
<input type="hidden" name="dir" value="{{.Data.Dir}}">
<input type="hidden" name="per" value="{{.Data.PerPage}}">
<label>
<span class="sr-only">Search</span>
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
</label>
<button class="button button-primary button-sm" type="submit">Search</button>
{{if .Data.Query}}<a class="button button-outline button-sm" href="/admin/files">Clear</a>{{end}}
</form>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
{{range .Data.Columns}}
<th><a class="sort-link {{if .Sorted}}is-sorted{{end}}" href="{{.Href}}">{{.Label}}{{if .Sorted}}<span class="sort-arrow" aria-hidden="true">{{if .Ascending}}▲{{else}}▼{{end}}</span>{{end}}</a></th>
{{end}}
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Data.Boxes}}
<tr>
<td><a href="/admin/boxes/{{.ID}}/edit"><code>{{.ID}}</code></a></td>
<td>{{.Owner}}</td>
<td>{{.FileCount}}</td>
<td>{{.TotalSizeLabel}}</td>
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td>
<td>
{{if .Expired}}<span class="badge">expired</span>{{else}}<span class="badge">active</span>{{end}}
{{if .Protected}}<span class="badge">protected</span>{{end}}
</td>
<td class="table-actions">
<a class="button button-primary button-sm" href="/admin/boxes/{{.ID}}/edit">Edit</a>
<a class="button button-outline button-sm" href="/admin/boxes/{{.ID}}/view">View</a>
<form action="/admin/boxes/{{.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit">Delete</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="9">No boxes match.</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="pagination-bar">
<nav class="pagination" aria-label="Pagination">
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
</nav>
<label class="per-page-control">
<span>Per page</span>
<select data-per-page="files" aria-label="Items per page">
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
</div>
<p class="pagination-summary">Showing {{.Data.RangeFrom}}{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,121 @@
{{define "admin_logs.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-logs-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link is-active" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a></nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-logs-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">Browse JSON log lines from the local log files.</p>
</div>
</div>
<form class="logs-filter-card" method="get" action="/admin/logs">
<label><span>Date</span>
<select name="date">
<option value="all" {{if eq .Data.Logs.Date "all"}}selected{{end}}>All dates</option>
{{range .Data.Logs.Dates}}<option value="{{.}}" {{if eq $.Data.Logs.Date .}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label><span>Severity</span>
<select name="severity">
<option value="" {{if eq .Data.Logs.Severity ""}}selected{{end}}>All</option>
<option value="dev" {{if eq .Data.Logs.Severity "dev"}}selected{{end}}>dev</option>
<option value="user_activity" {{if eq .Data.Logs.Severity "user_activity"}}selected{{end}}>user_activity</option>
<option value="warn" {{if eq .Data.Logs.Severity "warn"}}selected{{end}}>warn</option>
<option value="error" {{if eq .Data.Logs.Severity "error"}}selected{{end}}>error</option>
</select>
</label>
<label><span>Source</span><input name="source" value="{{.Data.Logs.Source}}" placeholder="auth, admin, upload"></label>
<label><span>Search</span><input name="q" value="{{.Data.Logs.Query}}" placeholder="message, IP, path, user id"></label>
<label><span>Sort</span>
<select name="sort">
<option value="desc" {{if eq .Data.Logs.Sort "desc"}}selected{{end}}>Newest first</option>
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
</select>
</label>
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
<button class="button button-primary" type="submit">Filter</button>
</form>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Log entries</h2>
<p>{{.Data.Logs.Total}} entries match these filters.</p>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table logs-table">
<thead>
<tr>
<th>Time</th>
<th>Severity</th>
<th>Source</th>
<th>Code</th>
<th>Message</th>
<th>Actor/IP</th>
<th>Route</th>
</tr>
</thead>
<tbody>
{{range .Data.Logs.Entries}}
<tr>
<td><span class="log-time">{{.Date}} {{.Time}}</span></td>
<td><span class="badge">{{.Severity}}</span></td>
<td>{{.Source}}</td>
<td>{{.Code}}</td>
<td>
<strong>{{.Message}}</strong>
{{if .Details}}<details><summary>Details</summary><code>{{.Details}}</code></details>{{end}}
</td>
<td>{{if .UserID}}<code>{{.UserID}}</code>{{end}}{{if .IP}}<br><span>{{.IP}}</span>{{end}}</td>
<td>{{if .Method}}{{.Method}}{{end}} {{if .Path}}<code>{{.Path}}</code>{{end}}{{if .Status}}<br><span>Status {{.Status}}</span>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="7">No log entries match those filters.</td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="pagination-bar">
<nav class="pagination" aria-label="Pagination">
{{if .Data.Logs.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.Logs.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
{{range .Data.Logs.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
{{if .Data.Logs.HasNext}}<a class="button button-outline button-sm" href="{{.Data.Logs.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
</nav>
<label class="per-page-control">
<span>Per page</span>
<select data-per-page="logs" aria-label="Items per page">
{{range .Data.Logs.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.Logs.PerPage}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
</div>
<p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -9,6 +9,8 @@
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a> <a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link is-active" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a> <a class="sidebar-link is-active" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a> <a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">

View File

@@ -9,6 +9,8 @@
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a> <a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a> <a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a> <a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -74,6 +76,7 @@
{{if eq .Config.ID "local"}}<span class="badge">Required</span> {{if eq .Config.ID "local"}}<span class="badge">Required</span>
{{else if .Config.Enabled}}<span class="badge badge-active">Enabled</span> {{else if .Config.Enabled}}<span class="badge badge-active">Enabled</span>
{{else}}<span class="badge badge-disabled">Disabled</span>{{end}} {{else}}<span class="badge badge-disabled">Disabled</span>{{end}}
{{if .InUseReason}}<span class="badge" title="{{.InUseReason}}">In use</span>{{end}}
{{if .UsageLabel}}<span class="storage-card-usage">{{.UsageLabel}}</span>{{end}} {{if .UsageLabel}}<span class="storage-card-usage">{{.UsageLabel}}</span>{{end}}
</div> </div>
</div> </div>
@@ -90,15 +93,9 @@
{{end}} {{end}}
{{if ne .Config.ID "local"}} {{if ne .Config.ID "local"}}
<a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/edit">Edit</a> <a class="button button-outline button-sm" href="/admin/storage/{{.Config.ID}}/edit">Edit</a>
{{if .Config.Enabled}}
<form action="/admin/storage/{{.Config.ID}}/disable" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline button-sm" type="submit" {{if .InUse}}disabled title="Backend is in use"{{end}}>Disable</button>
</form>
{{end}}
<form action="/admin/storage/{{.Config.ID}}/delete" method="post"> <form action="/admin/storage/{{.Config.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit" {{if .InUse}}disabled title="Backend is in use"{{end}}>Delete</button> <button class="button button-danger button-sm" type="submit" data-storage-delete-warning="{{.Config.Name}}">Delete</button>
</form> </form>
{{end}} {{end}}
</div> </div>

View File

@@ -9,6 +9,8 @@
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a> <a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a> <a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a> <a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">

View File

@@ -9,6 +9,8 @@
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a> <a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a> <a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a> <a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">

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