Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b4487ac2e | |||
| ead4cd7492 | |||
| af1fae1a98 | |||
| d11aec96e5 | |||
| dbfdacc396 | |||
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 | |||
| f9755fa98f | |||
| 2eba04b9da | |||
| 81f4ce5e36 | |||
| eff831b142 | |||
| 3b278642dc | |||
| 3a0dd04e61 | |||
| e17c5e92a7 | |||
| f698ba516d | |||
| 17c31be8b4 | |||
| 313c89483c | |||
| 5cd476e7f3 | |||
| d3b6a86753 | |||
| cf5d8bb50d | |||
| 8e3f783780 | |||
| 6c87187c6d | |||
| f628b489af | |||
| 1ab5021667 | |||
| c9f865cd85 | |||
| 38afc6c34d | |||
| 9a5be44a7f | |||
| 48722f0aab | |||
| 94cf9531fa | |||
| 60d2ea0204 | |||
| ffa2d9636b | |||
| cc91ce120d | |||
| 73bd14572d | |||
| 4eacb4cde2 | |||
| 71d9b9db7e | |||
| 01996c0445 | |||
| adb1a12dfd | |||
| 10ed806153 | |||
| 2d04a42736 | |||
| 42449b3322 |
11
.env.example
@@ -9,6 +9,11 @@ WARPBOX_CLEANUP_ENABLED=true
|
|||||||
WARPBOX_CLEANUP_EVERY=1h
|
WARPBOX_CLEANUP_EVERY=1h
|
||||||
WARPBOX_THUMBNAIL_ENABLED=true
|
WARPBOX_THUMBNAIL_ENABLED=true
|
||||||
WARPBOX_THUMBNAIL_EVERY=1m
|
WARPBOX_THUMBNAIL_EVERY=1m
|
||||||
|
WARPBOX_RESUMABLE_UPLOADS_ENABLED=true
|
||||||
|
WARPBOX_RESUMABLE_CHUNK_MB=64
|
||||||
|
WARPBOX_RESUMABLE_RETENTION_HOURS=1
|
||||||
|
WARPBOX_RESUMABLE_CHUNK_MODE=same
|
||||||
|
WARPBOX_RESUMABLE_CHUNK_PATH=
|
||||||
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
|
||||||
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
|
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
|
||||||
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
|
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
|
||||||
@@ -27,6 +32,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
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
111
PLANS.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Warpbox Plans & Staged Development
|
||||||
|
|
||||||
|
This document captures the staged development history and roadmap for Warpbox. For day-to-day usage,
|
||||||
|
configuration, and deployment, see [README.md](./README.md).
|
||||||
|
|
||||||
|
## Stage 2 — Operator Tools
|
||||||
|
|
||||||
|
- `/admin/login` - token-based admin login.
|
||||||
|
- `/admin` - overview metrics: boxes, files, storage, recent uploads, protected/expired boxes.
|
||||||
|
- `/admin/files` - recent upload table with view and delete actions.
|
||||||
|
- Expired boxes and boxes that have reached their download limit are cleaned on startup and then
|
||||||
|
every `WARPBOX_CLEANUP_EVERY` when `WARPBOX_CLEANUP_ENABLED=true`.
|
||||||
|
- Missing image/video thumbnails are generated in a background worker every `WARPBOX_THUMBNAIL_EVERY`
|
||||||
|
when `WARPBOX_THUMBNAIL_ENABLED=true`.
|
||||||
|
|
||||||
|
## Stage 3 — Anonymous Integrations
|
||||||
|
|
||||||
|
Anonymous uploads return a private management link at creation time. Keep that link secret: anyone
|
||||||
|
with it can delete the entire upload box. The raw delete token is not stored and cannot be recovered
|
||||||
|
later.
|
||||||
|
|
||||||
|
Browser uploads still show `Open box` and `Copy URL` as the primary actions, with a smaller
|
||||||
|
`Manage or delete this upload` link in the completion panel.
|
||||||
|
|
||||||
|
Curl and custom uploaders can use the same endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal-friendly output: one plain box URL.
|
||||||
|
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
||||||
|
|
||||||
|
# JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
||||||
|
curl -F sharex=@./screenshot.png \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
http://localhost:8080/api/v1/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start from
|
||||||
|
`examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
||||||
|
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header — mint a
|
||||||
|
token under **Account → Access tokens**. The JSON response uses ShareX placeholders `{json:boxUrl}`
|
||||||
|
(URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and `{json:error}` (error
|
||||||
|
message).
|
||||||
|
|
||||||
|
### Grouping multiple files into one box (`X-Warpbox-Batch`)
|
||||||
|
|
||||||
|
By default every uploaded file becomes its own box. To put several files in a **single** box, send
|
||||||
|
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
|
||||||
|
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
|
||||||
|
ShareX selection — which ShareX sends as separate back-to-back requests — land as one shareable link.
|
||||||
|
The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file. Requests
|
||||||
|
without the header behave exactly as before.
|
||||||
|
|
||||||
|
### Resumable API flow
|
||||||
|
|
||||||
|
Custom clients can use the resumable JSON API for large files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a resumable session from file metadata.
|
||||||
|
curl -s http://localhost:8080/api/v1/uploads/resumable \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"files":[{"name":"large.bin","size":1048576,"contentType":"application/octet-stream"}],"expiresMinutes":1440}'
|
||||||
|
|
||||||
|
# 2. Upload exact-sized chunks using the returned sessionId, file id, and chunkSize.
|
||||||
|
dd if=./large.bin bs=8388608 count=1 skip=0 2>/dev/null | \
|
||||||
|
curl -X PUT --data-binary @- \
|
||||||
|
http://localhost:8080/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
|
||||||
|
|
||||||
|
# 3. Complete the session after all chunks are present.
|
||||||
|
curl -X POST -H 'Accept: application/json' \
|
||||||
|
http://localhost:8080/api/v1/uploads/resumable/SESSION_ID/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
The complete response is the same JSON shape as `POST /api/v1/upload`, including `boxUrl`,
|
||||||
|
`manageUrl`, `deleteUrl`, and file URLs. Send `Authorization: Bearer <token>` on every resumable
|
||||||
|
request to upload as an account.
|
||||||
|
|
||||||
|
Browser resumable uploads are configurable from `/admin/settings`: enabled/disabled, chunk size in
|
||||||
|
MB, draft retention hours, and chunk staging location. The default chunk mode stores temporary draft
|
||||||
|
chunks under `data/tmp/uploads/{session_id}`. A custom mode can point those chunks at another local
|
||||||
|
path, such as a mounted fast SSD. Chunk staging stays local even when the completed files are later
|
||||||
|
finalized into S3/SFTP/SMB/WebDAV storage. Completion returns the share link immediately; files may
|
||||||
|
show as `Processing` on the download page while the background finalizer streams them to the selected
|
||||||
|
storage backend. Cleanup removes expired uploading drafts but skips sessions already in processing.
|
||||||
|
|
||||||
|
## Stage 4 — Accounts + Personal Boxes
|
||||||
|
|
||||||
|
- `/register` bootstraps the first admin account only when no users exist.
|
||||||
|
- `/login` and `/logout` provide cookie-based web sessions.
|
||||||
|
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
||||||
|
history, and flat collections. Uploading still happens from the homepage.
|
||||||
|
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
|
||||||
|
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is stored
|
||||||
|
with owner and optional collection metadata.
|
||||||
|
- Admin users are exempt from the global max upload size on the homepage upload flow. Future per-user
|
||||||
|
quotas should apply to this same upload path rather than creating a second uploader.
|
||||||
|
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
||||||
|
user storage quota, and usage retention.
|
||||||
|
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
||||||
|
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
|
||||||
|
- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and
|
||||||
|
repeated login failures. Auto-ban is off by default and configured from the admin UI.
|
||||||
|
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
|
||||||
|
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
|
||||||
|
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend. The
|
||||||
|
bbolt database and JSON logs remain local under `./data/db` and `./data/logs`.
|
||||||
|
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
|
||||||
|
tokens, thumbnails, and cleanup continue to work as before.
|
||||||
|
|
||||||
|
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
||||||
|
support will power public forgot-password and optional email delivery.
|
||||||
376
README.md
@@ -1,6 +1,20 @@
|
|||||||
# Warpbox.dev
|
# Warpbox.dev
|
||||||
|
|
||||||
This repository contains the Go backend base for `warpbox.dev`, a self-hosted transfer-first file sharing application.
|
Warpbox is a self-hosted, transfer first file sharing application written in Go. It renders
|
||||||
|
server side templates and serves static assets directly.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Anonymous and authenticated uploads from the browser, `curl`, or ShareX.
|
||||||
|
- Warpbox-native resumable uploads with a JSON API for large files.
|
||||||
|
- Upload boxes with expiry, optional download limits, password protection, and one time delete tokens.
|
||||||
|
- User accounts with personal dashboards, collections, storage quotas, and invite based registration.
|
||||||
|
- Admin tooling for metrics, file management, storage backends, upload policy, and IP bans.
|
||||||
|
- Local and S3 compatible storage backends.
|
||||||
|
- Background jobs for cleanup and thumbnail generation.
|
||||||
|
- Emoji reaction packs loaded from the runtime data directory.
|
||||||
|
|
||||||
|
> Looking for the roadmap and the staged development history? See [PLANS.md](./PLANS.md).
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -10,11 +24,25 @@ This repository contains the Go backend base for `warpbox.dev`, a self-hosted tr
|
|||||||
|
|
||||||
The default server listens on `:8080`.
|
The default server listens on `:8080`.
|
||||||
|
|
||||||
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
|
For one off Go commands, run them from the backend module:
|
||||||
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
|
|
||||||
|
|
||||||
Upload policy defaults are also configured in megabytes and can later be changed from
|
```bash
|
||||||
`/admin/settings`:
|
cd backend
|
||||||
|
go run ./cmd/warpbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration comes from environment variables. The dev script sources `scripts/env/dev.env`.
|
||||||
|
|
||||||
|
### Upload size
|
||||||
|
|
||||||
|
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`. Fractions are
|
||||||
|
supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
|
||||||
|
|
||||||
|
### Upload policy defaults
|
||||||
|
|
||||||
|
These defaults can later be changed from `/admin/settings`:
|
||||||
|
|
||||||
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
|
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
|
||||||
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
|
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
|
||||||
@@ -33,34 +61,184 @@ 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_RESUMABLE_UPLOADS_ENABLED=true`
|
||||||
|
- `WARPBOX_RESUMABLE_CHUNK_MB=8`
|
||||||
|
- `WARPBOX_RESUMABLE_RETENTION_HOURS=24`
|
||||||
|
- `WARPBOX_RESUMABLE_CHUNK_MODE=same`
|
||||||
|
- `WARPBOX_RESUMABLE_CHUNK_PATH=`
|
||||||
|
- `WARPBOX_TRUSTED_PROXIES=` controls whether forwarded client IP headers are accepted only from
|
||||||
|
specific proxy IPs/CIDRs. See [SECURITY_PROXY.md](./SECURITY_PROXY.md).
|
||||||
|
|
||||||
|
Resumable settings are seeded from the environment and can then be edited from `/admin/settings`.
|
||||||
|
Saved admin settings override these env defaults. `WARPBOX_RESUMABLE_CHUNK_MODE=same` stores draft
|
||||||
|
chunks in the normal local temp path, `data/tmp/uploads/{session_id}` under `WARPBOX_DATA_DIR`.
|
||||||
|
`WARPBOX_RESUMABLE_CHUNK_MODE=custom` uses `WARPBOX_RESUMABLE_CHUNK_PATH` instead, for example a
|
||||||
|
mounted fast SSD path. Chunk storage is always local temporary staging; completed files are finalized
|
||||||
|
into the selected storage backend after all chunks arrive.
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Data directory
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
### Background jobs
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
|
- **Cleanup**: expired boxes and boxes that have reached their download limit are cleaned on startup
|
||||||
|
and then on schedule. Stale resumable sessions are removed after `WARPBOX_RESUMABLE_RETENTION_HOURS`.
|
||||||
|
- **Thumbnails**: missing image/video thumbnails are generated in a background worker.
|
||||||
|
|
||||||
|
## First run bootstrap
|
||||||
|
|
||||||
On a fresh data directory, visit `/register` to create the first account. That first user becomes
|
On a fresh data directory, visit `/register` to create the first account. That first user becomes
|
||||||
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
|
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
|
||||||
links from `/admin/users`.
|
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 exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it at
|
||||||
at `/admin/login` if you need to recover access without a user session.
|
`/admin/login` if you need to recover access without a user session.
|
||||||
|
|
||||||
For one-off Go commands, run them from the backend module:
|
## Uploads
|
||||||
|
|
||||||
|
Browser uploads use Warpbox native resumable uploads by default. Resumable behavior is configurable
|
||||||
|
from `/admin/settings`, including enable/disable, chunk size, retention, and whether chunks use the
|
||||||
|
default local temp path or a custom local path such as a fast SSD. When all chunks arrive, Warpbox
|
||||||
|
returns the share link immediately and marks files as `Processing` until the background finalizer
|
||||||
|
streams them into the selected storage backend. Draft chunks are deleted once finalization succeeds.
|
||||||
|
Expired uploading drafts are cleaned after the configured retention window; sessions already in
|
||||||
|
`Processing` are protected from cleanup while finalization is running.
|
||||||
|
|
||||||
|
### Anonymous uploads
|
||||||
|
|
||||||
|
Anonymous uploads return a private management link at creation time. Keep that link secret: anyone
|
||||||
|
with it can delete the entire upload box. The raw delete token is not stored and cannot be recovered
|
||||||
|
later. Browser uploads show `Open box` and `Copy URL` as the primary actions, with a smaller
|
||||||
|
`Manage or delete this upload` link in the completion panel.
|
||||||
|
|
||||||
|
### `curl` and ShareX
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
# Terminal-friendly output: one plain box URL.
|
||||||
go run ./cmd/warpbox
|
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
||||||
|
|
||||||
|
# JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
||||||
|
curl -F sharex=@./screenshot.png \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
http://localhost:8080/api/v1/upload
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker / Podman
|
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start from
|
||||||
|
`examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
||||||
|
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header mint a
|
||||||
|
token under **Account → Access tokens**. The JSON response uses ShareX placeholders `{json:boxUrl}`
|
||||||
|
(URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and `{json:error}` (error
|
||||||
|
message).
|
||||||
|
|
||||||
|
### Grouping multiple files into one box (`X-Warpbox-Batch`)
|
||||||
|
|
||||||
|
By default every uploaded file becomes its own box. To put several files in a **single** box, send
|
||||||
|
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
|
||||||
|
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
|
||||||
|
ShareX selection which ShareX sends as separate back-to-back requests land as one shareable link.
|
||||||
|
The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file. Requests
|
||||||
|
without the header behave exactly as before.
|
||||||
|
|
||||||
|
### Resumable API flow
|
||||||
|
|
||||||
|
Custom clients can use the resumable JSON API for large files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a resumable session from file metadata.
|
||||||
|
curl -s http://localhost:8080/api/v1/uploads/resumable \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"files":[{"name":"large.bin","size":1048576,"contentType":"application/octet-stream"}],"expiresMinutes":1440}'
|
||||||
|
|
||||||
|
# 2. Upload exact-sized chunks using the returned sessionId, file id, and chunkSize.
|
||||||
|
dd if=./large.bin bs=8388608 count=1 skip=0 2>/dev/null | \
|
||||||
|
curl -X PUT --data-binary @- \
|
||||||
|
http://localhost:8080/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
|
||||||
|
|
||||||
|
# 3. Complete the session after all chunks are present.
|
||||||
|
curl -X POST -H 'Accept: application/json' \
|
||||||
|
http://localhost:8080/api/v1/uploads/resumable/SESSION_ID/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
The complete response is the same JSON shape as `POST /api/v1/upload`, including `boxUrl`,
|
||||||
|
`manageUrl`, `deleteUrl`, and file URLs. Send `Authorization: Bearer <token>` on every resumable
|
||||||
|
request to upload as an account.
|
||||||
|
|
||||||
|
## Accounts and admin
|
||||||
|
|
||||||
|
- `/register` bootstraps the first admin account only when no users exist.
|
||||||
|
- `/login` and `/logout` provide cookie-based web sessions.
|
||||||
|
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
||||||
|
history, and flat collections. Uploading still happens from the homepage.
|
||||||
|
- `/admin` shows overview metrics: boxes, files, storage, recent uploads, protected/expired boxes.
|
||||||
|
- `/admin/files` is a recent upload table with view and delete actions.
|
||||||
|
- `/admin/users` lets admins create invite links, disable/reactivate users, generate reset links,
|
||||||
|
view storage/daily usage, and set per-user storage quota overrides.
|
||||||
|
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
||||||
|
user storage quota, and usage retention.
|
||||||
|
- `/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.
|
||||||
|
|
||||||
|
Logged-in browser uploads from `/` use `POST /api/v1/upload`, and the resulting box is stored with
|
||||||
|
owner and optional collection metadata. Admin users are exempt from the global max upload size on the
|
||||||
|
homepage upload flow.
|
||||||
|
|
||||||
|
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
||||||
|
support will power public forgot-password and optional email delivery.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker / Podman
|
||||||
|
|
||||||
Copy the example environment file and adjust values such as `WARPBOX_BASE_URL` and
|
Copy the example environment file and adjust values such as `WARPBOX_BASE_URL` and
|
||||||
`WARPBOX_ADMIN_TOKEN` before running the container:
|
`WARPBOX_ADMIN_TOKEN` before running the container. Copy the example
|
||||||
|
[docker-compose.example.yml](./docker-compose.example.yml) to
|
||||||
Copy the example [docker-compose.example.yml](./docker-compose.example.yml) to [docker-compose.yml](./docker-compose.yml), modify as need-be
|
[docker-compose.yml](./docker-compose.yml), modify as need-be:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -68,13 +246,97 @@ docker compose -f docker-compose.yml up --build
|
|||||||
```
|
```
|
||||||
|
|
||||||
The compose example also works with Podman compatible compose tools. Its data volume uses
|
The compose example also works with Podman compatible compose tools. Its data volume uses
|
||||||
`./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`,
|
||||||
`/data`, `/app/static`, and `/app/templates`.
|
`/app/static`, and `/app/templates`. The image exposes the health endpoint `/health`, which Docker
|
||||||
|
and compose healthchecks use.
|
||||||
|
|
||||||
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks
|
### Reverse proxy security
|
||||||
use `/health`.
|
|
||||||
|
|
||||||
## Layout
|
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.
|
||||||
|
|
||||||
|
## Runtime data
|
||||||
|
|
||||||
|
Warpbox keeps local runtime data under the configured data directory:
|
||||||
|
|
||||||
|
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents when the local backend is selected.
|
||||||
|
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews when the local backend is selected.
|
||||||
|
- `data/tmp/uploads/{session_id}` - temporary local chunks for unfinished resumable uploads when
|
||||||
|
the default chunk mode is selected.
|
||||||
|
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes, file records, users, sessions,
|
||||||
|
invites, collections, upload policy settings, daily usage records, manual bans, automatic ban
|
||||||
|
settings, abuse counters, and malicious path rules.
|
||||||
|
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
||||||
|
|
||||||
|
Uploaded file content, thumbnails, and private box metadata use the selected storage backend. The
|
||||||
|
bbolt database and JSON logs always remain local under `./data/db` and `./data/logs`.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
- `backend/cmd/warpbox` - main application entry point.
|
- `backend/cmd/warpbox` - main application entry point.
|
||||||
- `backend/libs/config` - environment-backed configuration.
|
- `backend/libs/config` - environment-backed configuration.
|
||||||
@@ -82,7 +344,7 @@ use `/health`.
|
|||||||
- `backend/libs/handlers` - HTTP handlers for pages, API, health, static files.
|
- `backend/libs/handlers` - HTTP handlers for pages, API, health, static files.
|
||||||
- `backend/libs/jobs` - background job registration and job loop definitions.
|
- `backend/libs/jobs` - background job registration and job loop definitions.
|
||||||
- `backend/libs/middleware` - request logging, recovery, security headers, gzip, request IDs.
|
- `backend/libs/middleware` - request logging, recovery, security headers, gzip, request IDs.
|
||||||
- `backend/libs/services` - business logic boundaries, starting with upload limits.
|
- `backend/libs/services` - business logic boundaries.
|
||||||
- `backend/libs/helpers` - small reusable helpers.
|
- `backend/libs/helpers` - small reusable helpers.
|
||||||
- `backend/libs/web` - Go template renderer.
|
- `backend/libs/web` - Go template renderer.
|
||||||
- `backend/templates` - server-rendered Go templates.
|
- `backend/templates` - server-rendered Go templates.
|
||||||
@@ -91,75 +353,13 @@ use `/health`.
|
|||||||
- `scripts/env/dev.env.example` - tracked development environment template.
|
- `scripts/env/dev.env.example` - tracked development environment template.
|
||||||
- `scripts/env/dev.env` - local development environment, ignored by git.
|
- `scripts/env/dev.env` - local development environment, ignored by git.
|
||||||
|
|
||||||
## Stage 2 Operator Tools
|
## Static asset policy
|
||||||
|
|
||||||
- `/admin/login` - token-based admin login.
|
The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter
|
||||||
- `/admin` - overview metrics: boxes, files, storage, recent uploads, protected/expired boxes.
|
caching for CSS/JS, and gzip compression for compressible responses.
|
||||||
- `/admin/files` - recent upload table with view and delete actions.
|
|
||||||
- Expired boxes and boxes that have reached their download limit are cleaned on startup and then every `WARPBOX_CLEANUP_EVERY` when `WARPBOX_CLEANUP_ENABLED=true`.
|
|
||||||
- Missing image/video thumbnails are generated in a background worker every `WARPBOX_THUMBNAIL_EVERY` when `WARPBOX_THUMBNAIL_ENABLED=true`.
|
|
||||||
|
|
||||||
## Stage 3 Anonymous Integrations
|
## AI Usage
|
||||||
|
|
||||||
Anonymous uploads now return a private management link at creation time. Keep that link secret:
|
I have used AI to accelerate development, all of the code has been reviewed by humans. I have mostly used self-hosted models as well as big models from big companies for a monthly subscription fee.
|
||||||
anyone with it can delete the entire upload box. The raw delete token is not stored and cannot be
|
|
||||||
recovered later.
|
|
||||||
|
|
||||||
Browser uploads still show `Open box` and `Copy URL` as the primary actions, with a smaller
|
I have nothing against AI as long as you can tell me what every single line of your code does. That's how I personally view things.
|
||||||
`Manage or delete this upload` link in the completion panel.
|
|
||||||
|
|
||||||
Curl and custom uploaders can use the same endpoint:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal-friendly output: one plain box URL.
|
|
||||||
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
|
||||||
|
|
||||||
# JSON output with boxUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
|
||||||
curl -F sharex=@./screenshot.png \
|
|
||||||
-H 'Accept: application/json' \
|
|
||||||
http://localhost:8080/api/v1/upload
|
|
||||||
```
|
|
||||||
|
|
||||||
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
|
||||||
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
|
||||||
|
|
||||||
## Stage 4 Accounts + Personal Boxes
|
|
||||||
|
|
||||||
- `/register` bootstraps the first admin account only when no users exist.
|
|
||||||
- `/login` and `/logout` provide cookie-based web sessions.
|
|
||||||
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
|
||||||
history, and flat collections. Uploading still happens from the homepage.
|
|
||||||
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
|
|
||||||
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is
|
|
||||||
stored with owner and optional collection metadata.
|
|
||||||
- Admin users are exempt from the global max upload size on the homepage upload flow. Future
|
|
||||||
per-user quotas should apply to this same upload path rather than creating a second uploader.
|
|
||||||
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
|
||||||
user storage quota, and usage retention.
|
|
||||||
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
|
||||||
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
|
|
||||||
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
|
|
||||||
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
|
|
||||||
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
|
|
||||||
The bbolt database and JSON logs remain local under `./data/db` and `./data/logs`.
|
|
||||||
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
|
|
||||||
tokens, thumbnails, and cleanup continue to work as before.
|
|
||||||
|
|
||||||
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
|
||||||
support will power public forgot-password and optional email delivery.
|
|
||||||
|
|
||||||
## Runtime Data
|
|
||||||
|
|
||||||
Warpbox keeps local runtime data under the configured data directory:
|
|
||||||
|
|
||||||
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents when the local backend is selected.
|
|
||||||
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews when the local backend is selected.
|
|
||||||
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
|
|
||||||
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
|
|
||||||
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
|
|
||||||
for anonymous uploads and user ID for signed-in uploads.
|
|
||||||
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
|
||||||
|
|
||||||
## Static Asset Policy
|
|
||||||
|
|
||||||
The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter caching for CSS/JS, and gzip compression for compressible responses.
|
|
||||||
87
SECURITY_PROXY.md
Normal 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`.
|
||||||
@@ -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,14 +20,19 @@ 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
|
||||||
ThumbnailEnabled bool
|
ThumbnailEnabled bool
|
||||||
ThumbnailEvery time.Duration
|
ThumbnailEvery time.Duration
|
||||||
|
ResumableUploadsEnabled bool
|
||||||
|
ResumableChunkSize int64
|
||||||
|
ResumableRetention time.Duration
|
||||||
MaxUploadSize int64
|
MaxUploadSize int64
|
||||||
DefaultSettings SettingsDefaults
|
DefaultSettings SettingsDefaults
|
||||||
}
|
}
|
||||||
@@ -49,11 +55,17 @@ type SettingsDefaults struct {
|
|||||||
ShortWindowSeconds int
|
ShortWindowSeconds int
|
||||||
AnonymousStorageBackend string
|
AnonymousStorageBackend string
|
||||||
UserStorageBackend string
|
UserStorageBackend string
|
||||||
|
ResumableUploadsEnabled bool
|
||||||
|
ResumableChunkSizeMB float64
|
||||||
|
ResumableRetentionHours int
|
||||||
|
ResumableChunkMode string
|
||||||
|
ResumableChunkPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||||
|
AppVersion: envString("APP_VERSION", "dev"),
|
||||||
Environment: envString("WARPBOX_ENV", "development"),
|
Environment: envString("WARPBOX_ENV", "development"),
|
||||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||||
@@ -61,14 +73,19 @@ 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),
|
||||||
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
||||||
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
||||||
|
ResumableUploadsEnabled: envBool("WARPBOX_RESUMABLE_UPLOADS_ENABLED", true),
|
||||||
|
ResumableChunkSize: envMegabytes("WARPBOX_RESUMABLE_CHUNK_MB", 8),
|
||||||
|
ResumableRetention: time.Duration(envInt("WARPBOX_RESUMABLE_RETENTION_HOURS", 24)) * time.Hour,
|
||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
||||||
DefaultSettings: SettingsDefaults{
|
DefaultSettings: SettingsDefaults{
|
||||||
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||||
@@ -88,8 +105,13 @@ func Load() (Config, error) {
|
|||||||
ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60),
|
ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60),
|
||||||
AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_STORAGE_BACKEND", "local"),
|
AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_STORAGE_BACKEND", "local"),
|
||||||
UserStorageBackend: envString("WARPBOX_USER_STORAGE_BACKEND", "local"),
|
UserStorageBackend: envString("WARPBOX_USER_STORAGE_BACKEND", "local"),
|
||||||
|
ResumableChunkMode: envString("WARPBOX_RESUMABLE_CHUNK_MODE", "same"),
|
||||||
|
ResumableChunkPath: envString("WARPBOX_RESUMABLE_CHUNK_PATH", ""),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cfg.DefaultSettings.ResumableUploadsEnabled = cfg.ResumableUploadsEnabled
|
||||||
|
cfg.DefaultSettings.ResumableChunkSizeMB = float64(cfg.ResumableChunkSize) / 1024 / 1024
|
||||||
|
cfg.DefaultSettings.ResumableRetentionHours = int(cfg.ResumableRetention / time.Hour)
|
||||||
|
|
||||||
if cfg.BaseURL == "" {
|
if cfg.BaseURL == "" {
|
||||||
return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty")
|
return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty")
|
||||||
@@ -97,6 +119,12 @@ func Load() (Config, error) {
|
|||||||
if cfg.MaxUploadSize <= 0 {
|
if cfg.MaxUploadSize <= 0 {
|
||||||
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
|
||||||
}
|
}
|
||||||
|
if cfg.ResumableChunkSize <= 0 {
|
||||||
|
return Config{}, fmt.Errorf("WARPBOX_RESUMABLE_CHUNK_MB must be positive")
|
||||||
|
}
|
||||||
|
if cfg.ResumableRetention <= 0 {
|
||||||
|
return Config{}, fmt.Errorf("WARPBOX_RESUMABLE_RETENTION_HOURS must be positive")
|
||||||
|
}
|
||||||
if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) ||
|
if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) ||
|
||||||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) ||
|
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) ||
|
||||||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) ||
|
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) ||
|
||||||
@@ -178,6 +206,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 == "" {
|
||||||
|
|||||||
@@ -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,29 @@ 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)
|
||||||
|
}
|
||||||
|
if !cfg.ResumableUploadsEnabled {
|
||||||
|
t.Fatalf("ResumableUploadsEnabled = false, want true")
|
||||||
|
}
|
||||||
|
if cfg.ResumableChunkSize != 8*1024*1024 {
|
||||||
|
t.Fatalf("ResumableChunkSize = %d, want 8 MiB", cfg.ResumableChunkSize)
|
||||||
|
}
|
||||||
|
if cfg.ResumableRetention != 24*time.Hour {
|
||||||
|
t.Fatalf("ResumableRetention = %s, want 24h", cfg.ResumableRetention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
492
backend/libs/handlers/admin_files.go
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
"warpbox.dev/backend/libs/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminFilesDefaultPageSize = 50
|
||||||
|
|
||||||
|
var adminFilesPageSizes = []int{25, 50, 100, 200}
|
||||||
|
|
||||||
|
type adminFilesData struct {
|
||||||
|
Stats services.AdminStats
|
||||||
|
Section string
|
||||||
|
PageTitle string
|
||||||
|
Boxes []adminBoxView
|
||||||
|
Query string
|
||||||
|
Sort string
|
||||||
|
Dir string
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
PerPageOptions []int
|
||||||
|
TotalPages int
|
||||||
|
Total int
|
||||||
|
RangeFrom int
|
||||||
|
RangeTo int
|
||||||
|
Columns []adminFilesColumn
|
||||||
|
PageLinks []adminFilesPageLink
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevHref string
|
||||||
|
NextHref string
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminFilesQuery captures the listing state that every paginated link must
|
||||||
|
// preserve.
|
||||||
|
type adminFilesQuery struct {
|
||||||
|
Query string
|
||||||
|
Sort string
|
||||||
|
Dir string
|
||||||
|
Per int
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminFilesColumn struct {
|
||||||
|
Label string
|
||||||
|
Href string
|
||||||
|
Sorted bool
|
||||||
|
Ascending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminFilesPageLink struct {
|
||||||
|
Page int
|
||||||
|
Href string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxEditData struct {
|
||||||
|
Section string
|
||||||
|
PageTitle string
|
||||||
|
Box adminBoxDetail
|
||||||
|
Files []adminBoxEditFile
|
||||||
|
Notice string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxDetail struct {
|
||||||
|
ID string
|
||||||
|
Owner string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresLabel string
|
||||||
|
ExpiresInput string
|
||||||
|
NeverExpires bool
|
||||||
|
MaxDownloads int
|
||||||
|
DownloadCount int
|
||||||
|
FileCount int
|
||||||
|
TotalSize string
|
||||||
|
BackendID string
|
||||||
|
Protected bool
|
||||||
|
Obfuscated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxEditFile struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
ContentType string
|
||||||
|
ThumbnailURL string
|
||||||
|
DownloadURL string
|
||||||
|
HasPreview bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminFileRow is the sortable/filterable representation of a box.
|
||||||
|
type adminFileRow struct {
|
||||||
|
ID string
|
||||||
|
Owner string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
FileCount int
|
||||||
|
DownloadCount int
|
||||||
|
MaxDownloads int
|
||||||
|
TotalSize int64
|
||||||
|
TotalSizeLabel string
|
||||||
|
Protected bool
|
||||||
|
Expired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := a.uploadService.AdminStats()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boxes, err := a.uploadService.AdminBoxes(0)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerCache := map[string]string{}
|
||||||
|
rows := make([]adminFileRow, 0, len(boxes))
|
||||||
|
for _, box := range boxes {
|
||||||
|
rows = append(rows, adminFileRow{
|
||||||
|
ID: box.ID,
|
||||||
|
Owner: a.boxOwnerLabel(box.OwnerID, ownerCache),
|
||||||
|
CreatedAt: box.CreatedAt,
|
||||||
|
ExpiresAt: box.ExpiresAt,
|
||||||
|
FileCount: box.FileCount,
|
||||||
|
DownloadCount: box.DownloadCount,
|
||||||
|
MaxDownloads: box.MaxDownloads,
|
||||||
|
TotalSize: box.TotalSize,
|
||||||
|
TotalSizeLabel: box.TotalSizeLabel,
|
||||||
|
Protected: box.Protected,
|
||||||
|
Expired: box.Expired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
if query != "" {
|
||||||
|
needle := strings.ToLower(query)
|
||||||
|
filtered := rows[:0:0]
|
||||||
|
for _, row := range rows {
|
||||||
|
if strings.Contains(strings.ToLower(row.ID), needle) || strings.Contains(strings.ToLower(row.Owner), needle) {
|
||||||
|
filtered = append(filtered, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
sortKey := adminFilesSortKey(r.URL.Query().Get("sort"))
|
||||||
|
dir := r.URL.Query().Get("dir")
|
||||||
|
if dir != "asc" {
|
||||||
|
dir = "desc"
|
||||||
|
}
|
||||||
|
sortAdminFileRows(rows, sortKey, dir)
|
||||||
|
|
||||||
|
perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes)
|
||||||
|
state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage}
|
||||||
|
|
||||||
|
total := len(rows)
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
page := 1
|
||||||
|
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
|
||||||
|
page = parsed
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
start := (page - 1) * perPage
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]adminBoxView, 0, end-start)
|
||||||
|
for _, row := range rows[start:end] {
|
||||||
|
views = append(views, adminBoxView{
|
||||||
|
ID: row.ID,
|
||||||
|
Owner: row.Owner,
|
||||||
|
CreatedAt: row.CreatedAt.Format("Jan 2, 2006 15:04"),
|
||||||
|
ExpiresAt: boxExpiryLabel(row.ExpiresAt, "Jan 2, 2006 15:04"),
|
||||||
|
FileCount: row.FileCount,
|
||||||
|
TotalSizeLabel: row.TotalSizeLabel,
|
||||||
|
DownloadCount: row.DownloadCount,
|
||||||
|
MaxDownloads: row.MaxDownloads,
|
||||||
|
Protected: row.Protected,
|
||||||
|
Expired: row.Expired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeFrom := 0
|
||||||
|
if total > 0 {
|
||||||
|
rangeFrom = start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
a.renderPage(w, r, http.StatusOK, "admin_files.html", web.PageData{
|
||||||
|
Title: "Admin files",
|
||||||
|
Description: "Manage Warpbox uploads.",
|
||||||
|
CurrentUser: a.currentPublicUser(r),
|
||||||
|
Data: adminFilesData{
|
||||||
|
Stats: stats,
|
||||||
|
Section: "files",
|
||||||
|
PageTitle: "Files",
|
||||||
|
Boxes: views,
|
||||||
|
Query: query,
|
||||||
|
Sort: sortKey,
|
||||||
|
Dir: dir,
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
PerPageOptions: adminFilesPageSizes,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
Total: total,
|
||||||
|
RangeFrom: rangeFrom,
|
||||||
|
RangeTo: end,
|
||||||
|
Columns: adminFilesColumns(state, sortKey, dir),
|
||||||
|
PageLinks: adminFilesPageLinks(state, page, totalPages),
|
||||||
|
HasPrev: page > 1,
|
||||||
|
HasNext: page < totalPages,
|
||||||
|
PrevHref: adminFilesHref(state, page-1),
|
||||||
|
NextHref: adminFilesHref(state, page+1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) boxOwnerLabel(ownerID string, cache map[string]string) string {
|
||||||
|
if ownerID == "" {
|
||||||
|
return "Anonymous"
|
||||||
|
}
|
||||||
|
if label, ok := cache[ownerID]; ok {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
label := "User"
|
||||||
|
if user, err := a.authService.UserByID(ownerID); err == nil {
|
||||||
|
label = user.Email
|
||||||
|
}
|
||||||
|
cache[ownerID] = label
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminFilesSortKey(value string) string {
|
||||||
|
switch value {
|
||||||
|
case "id", "owner", "files", "size", "downloads", "expires", "created":
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return "created"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
|
||||||
|
less := func(i, j int) bool {
|
||||||
|
a, b := rows[i], rows[j]
|
||||||
|
switch sortKey {
|
||||||
|
case "id":
|
||||||
|
return strings.ToLower(a.ID) < strings.ToLower(b.ID)
|
||||||
|
case "owner":
|
||||||
|
return strings.ToLower(a.Owner) < strings.ToLower(b.Owner)
|
||||||
|
case "files":
|
||||||
|
return a.FileCount < b.FileCount
|
||||||
|
case "size":
|
||||||
|
return a.TotalSize < b.TotalSize
|
||||||
|
case "downloads":
|
||||||
|
return a.DownloadCount < b.DownloadCount
|
||||||
|
case "expires":
|
||||||
|
return a.ExpiresAt.Before(b.ExpiresAt)
|
||||||
|
default:
|
||||||
|
return a.CreatedAt.Before(b.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
if dir == "desc" {
|
||||||
|
return less(j, i)
|
||||||
|
}
|
||||||
|
return less(i, j)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
|
||||||
|
defs := []struct{ Key, Label string }{
|
||||||
|
{"id", "Box"},
|
||||||
|
{"owner", "Owner"},
|
||||||
|
{"files", "Files"},
|
||||||
|
{"size", "Size"},
|
||||||
|
{"downloads", "Downloads"},
|
||||||
|
{"created", "Created"},
|
||||||
|
{"expires", "Expires"},
|
||||||
|
}
|
||||||
|
columns := make([]adminFilesColumn, 0, len(defs))
|
||||||
|
for _, def := range defs {
|
||||||
|
sorted := sortKey == def.Key
|
||||||
|
nextDir := "asc"
|
||||||
|
if sorted && dir == "asc" {
|
||||||
|
nextDir = "desc"
|
||||||
|
}
|
||||||
|
colState := state
|
||||||
|
colState.Sort = def.Key
|
||||||
|
colState.Dir = nextDir
|
||||||
|
columns = append(columns, adminFilesColumn{
|
||||||
|
Label: def.Label,
|
||||||
|
Href: adminFilesHref(colState, 1),
|
||||||
|
Sorted: sorted,
|
||||||
|
Ascending: dir == "asc",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
|
||||||
|
links := make([]adminFilesPageLink, 0, 5)
|
||||||
|
const window = 2
|
||||||
|
for p := page - window; p <= page+window; p++ {
|
||||||
|
if p < 1 || p > totalPages {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links = append(links, adminFilesPageLink{
|
||||||
|
Page: p,
|
||||||
|
Href: adminFilesHref(state, p),
|
||||||
|
Active: p == page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminFilesHref(state adminFilesQuery, page int) string {
|
||||||
|
values := url.Values{}
|
||||||
|
if state.Query != "" {
|
||||||
|
values.Set("q", state.Query)
|
||||||
|
}
|
||||||
|
if state.Sort != "" && state.Sort != "created" {
|
||||||
|
values.Set("sort", state.Sort)
|
||||||
|
}
|
||||||
|
if state.Dir != "" && state.Dir != "desc" {
|
||||||
|
values.Set("dir", state.Dir)
|
||||||
|
}
|
||||||
|
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
|
||||||
|
values.Set("per", strconv.Itoa(state.Per))
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
values.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "/admin/files"
|
||||||
|
}
|
||||||
|
return "/admin/files?" + values.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePageSize parses a requested page size, falling back to def when the
|
||||||
|
// value is missing or not one of the allowed sizes.
|
||||||
|
func normalizePageSize(raw string, def int, allowed []int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
for _, size := range allowed {
|
||||||
|
if size == parsed {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSize int64
|
||||||
|
files := make([]adminBoxEditFile, 0, len(box.Files))
|
||||||
|
for _, file := range box.Files {
|
||||||
|
totalSize += file.Size
|
||||||
|
files = append(files, adminBoxEditFile{
|
||||||
|
ID: file.ID,
|
||||||
|
Name: file.Name,
|
||||||
|
Size: helpers.FormatBytes(file.Size),
|
||||||
|
ContentType: file.ContentType,
|
||||||
|
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
||||||
|
DownloadURL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||||
|
HasPreview: file.PreviewKind == "image" || file.PreviewKind == "video",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
never := neverExpires(box.ExpiresAt)
|
||||||
|
expiresInput := ""
|
||||||
|
if !never {
|
||||||
|
expiresInput = box.ExpiresAt.UTC().Format("2006-01-02T15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := map[string]string{}
|
||||||
|
a.renderPage(w, r, http.StatusOK, "admin_box_edit.html", web.PageData{
|
||||||
|
Title: "Edit box",
|
||||||
|
Description: "Edit a Warpbox upload.",
|
||||||
|
CurrentUser: a.currentPublicUser(r),
|
||||||
|
Data: adminBoxEditData{
|
||||||
|
Section: "files",
|
||||||
|
PageTitle: "Edit box",
|
||||||
|
Notice: r.URL.Query().Get("notice"),
|
||||||
|
Error: r.URL.Query().Get("error"),
|
||||||
|
Files: files,
|
||||||
|
Box: adminBoxDetail{
|
||||||
|
ID: box.ID,
|
||||||
|
Owner: a.boxOwnerLabel(box.OwnerID, cache),
|
||||||
|
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04 MST"),
|
||||||
|
ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"),
|
||||||
|
ExpiresInput: expiresInput,
|
||||||
|
NeverExpires: never,
|
||||||
|
MaxDownloads: box.MaxDownloads,
|
||||||
|
DownloadCount: box.DownloadCount,
|
||||||
|
FileCount: len(box.Files),
|
||||||
|
TotalSize: helpers.FormatBytes(totalSize),
|
||||||
|
BackendID: a.uploadService.BoxStorageBackendID(box),
|
||||||
|
Protected: a.uploadService.IsProtected(box),
|
||||||
|
Obfuscated: box.Obfuscate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminUpdateBox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boxID := r.PathValue("boxID")
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+read+form", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt time.Time
|
||||||
|
if r.FormValue("never_expires") == "on" {
|
||||||
|
expiresAt = time.Now().UTC().AddDate(100, 0, 0)
|
||||||
|
} else {
|
||||||
|
parsed, err := time.Parse("2006-01-02T15:04", strings.TrimSpace(r.FormValue("expires_at")))
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Invalid+expiration+date", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expiresAt = parsed.UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDownloads := parsePositiveInt(r.FormValue("max_downloads"))
|
||||||
|
removePassword := r.FormValue("remove_password") == "on"
|
||||||
|
|
||||||
|
if err := a.uploadService.AdminUpdateBox(boxID, expiresAt, maxDownloads, removePassword); err != nil {
|
||||||
|
a.logger.Warn("admin box update failed", "source", "admin", "severity", "warn", "code", 4306, "box_id", boxID, "error", err.Error())
|
||||||
|
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+save+changes", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("admin box updated", "source", "admin", "severity", "user_activity", "code", 2306, "ip", uploadClientIP(r), "box_id", boxID)
|
||||||
|
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=Changes+saved", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AdminDeleteBoxFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boxID := r.PathValue("boxID")
|
||||||
|
fileID := r.PathValue("fileID")
|
||||||
|
boxDeleted, err := a.uploadService.RemoveFileFromBox(boxID, fileID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("admin file delete failed", "source", "admin", "severity", "warn", "code", 4305, "box_id", boxID, "file_id", fileID, "error", err.Error())
|
||||||
|
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+remove+file", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("admin removed box file", "source", "admin", "severity", "user_activity", "code", 2305, "ip", uploadClientIP(r), "box_id", boxID, "file_id", fileID)
|
||||||
|
if boxDeleted {
|
||||||
|
http.Redirect(w, r, "/admin/files?notice=Box+deleted+(last+file+removed)", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=File+removed", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +54,8 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
|
|||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /", a.Home)
|
mux.HandleFunc("GET /", a.Home)
|
||||||
mux.HandleFunc("GET /api", a.APIDocs)
|
mux.HandleFunc("GET /api", a.APIDocs)
|
||||||
|
mux.HandleFunc("GET /service-worker.js", a.ServiceWorker)
|
||||||
|
mux.HandleFunc("POST /share-target", a.ShareTargetFallback)
|
||||||
mux.HandleFunc("GET /register", a.Register)
|
mux.HandleFunc("GET /register", a.Register)
|
||||||
mux.HandleFunc("POST /register", a.RegisterPost)
|
mux.HandleFunc("POST /register", a.RegisterPost)
|
||||||
mux.HandleFunc("GET /login", a.Login)
|
mux.HandleFunc("GET /login", a.Login)
|
||||||
@@ -67,6 +81,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 +106,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 +118,47 @@ 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}/f/{fileID}/og-image.jpg", a.FileOGImage)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing)
|
||||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||||
|
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||||
|
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
||||||
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("POST /api/v1/uploads/resumable", a.CreateResumableUpload)
|
||||||
|
mux.HandleFunc("GET /api/v1/uploads/resumable/{sessionID}", a.ResumableUploadStatus)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/files", a.AddResumableFiles)
|
||||||
|
mux.HandleFunc("PUT /api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}", a.PutResumableChunk)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/complete", a.CompleteResumableUpload)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/complete-uploaded", a.CompleteUploadedResumableUpload)
|
||||||
|
mux.HandleFunc("DELETE /api/v1/uploads/resumable/{sessionID}", a.CancelResumableUpload)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ 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"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
@@ -26,6 +30,7 @@ type downloadPageData struct {
|
|||||||
DownloadCount int
|
DownloadCount int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
ExpiresLabel string
|
ExpiresLabel string
|
||||||
|
EmojiTabs []emojiTabView
|
||||||
}
|
}
|
||||||
|
|
||||||
type boxView struct {
|
type boxView struct {
|
||||||
@@ -36,11 +41,46 @@ type fileView struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Size string
|
Size string
|
||||||
|
SizeBytes int64
|
||||||
ContentType string
|
ContentType string
|
||||||
PreviewKind string
|
PreviewKind string
|
||||||
URL string
|
URL string
|
||||||
DownloadURL string
|
DownloadURL string
|
||||||
ThumbnailURL string
|
ThumbnailURL string
|
||||||
|
SceneURL string
|
||||||
|
ArchiveURL string
|
||||||
|
HasThumbnail bool
|
||||||
|
HasScene bool
|
||||||
|
HasArchive bool
|
||||||
|
IconURL string
|
||||||
|
IconRetroURL string
|
||||||
|
ReactURL string
|
||||||
|
Reactions []reactionView
|
||||||
|
ReactionMore int
|
||||||
|
Reacted bool
|
||||||
|
Processing bool
|
||||||
|
Failed bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +93,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,26 +110,69 @@ 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)
|
||||||
|
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
||||||
|
file := box.Files[0]
|
||||||
|
if file.Processing {
|
||||||
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldServeRawSocialMedia(file) {
|
||||||
|
a.serveFileContent(w, r, box, file, false)
|
||||||
|
a.logger.Info("single-file media served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
||||||
|
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
|
||||||
|
imageType := "image/jpeg"
|
||||||
|
if !locked && len(box.Files) == 1 && !box.Files[0].Processing {
|
||||||
|
file := box.Files[0]
|
||||||
|
view := a.fileView(box, file)
|
||||||
|
fileSize := helpers.FormatBytes(file.Size)
|
||||||
|
title = file.Name
|
||||||
|
description = fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||||
|
ogImage = socialImageURL(r, box, file, view)
|
||||||
|
imageAlt = fmt.Sprintf("Download card for %s", file.Name)
|
||||||
|
imageType = socialImageType(file)
|
||||||
|
}
|
||||||
if locked && box.Obfuscate {
|
if locked && box.Obfuscate {
|
||||||
title = "Protected Warpbox link"
|
title = "Protected Warpbox link"
|
||||||
description = "This shared box is password protected."
|
description = "This shared box is password protected."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
|
||||||
|
|
||||||
|
// All user uploads are private/temporary — noindex by default.
|
||||||
|
robots := web.RobotsNone
|
||||||
|
|
||||||
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
|
CanonicalURL: pageURL,
|
||||||
|
Robots: robots,
|
||||||
|
ImageURL: ogImage,
|
||||||
|
ImageAlt: imageAlt,
|
||||||
|
ImageType: imageType,
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
Files: files,
|
Files: files,
|
||||||
@@ -97,8 +182,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 {
|
||||||
@@ -108,6 +195,43 @@ func plural(n int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldServeRawSocialMedia(file services.File) bool {
|
||||||
|
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
|
||||||
|
if strings.TrimSpace(contentType) == "" {
|
||||||
|
contentType = "file"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
|
if file.PreviewKind == "video" && view.HasThumbnail {
|
||||||
|
return absoluteURL(r, view.ThumbnailURL)
|
||||||
|
}
|
||||||
|
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageType(file services.File) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialOGType(file services.File) string {
|
||||||
|
switch file.PreviewKind {
|
||||||
|
case "video":
|
||||||
|
return "video.other"
|
||||||
|
default:
|
||||||
|
return "website"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -115,20 +239,70 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||||
|
if isSocialPreviewBot(r) && !locked {
|
||||||
|
if file.Processing {
|
||||||
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.ProcessingError != "" {
|
||||||
|
a.logger.Warn("failed file preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4241, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||||
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
a.logger.Warn("failed box preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4245, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
||||||
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldServeRawSocialMedia(file) {
|
||||||
|
a.serveFileContent(w, r, box, file, false)
|
||||||
|
a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if file.ProcessingError != "" && !locked {
|
||||||
|
a.logger.Warn("failed file preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4242, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||||
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) && !locked {
|
||||||
|
a.logger.Warn("failed box preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4246, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
||||||
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
view := a.fileView(box, file)
|
view := a.fileView(box, file)
|
||||||
|
fileSize := helpers.FormatBytes(file.Size)
|
||||||
title := file.Name
|
title := file.Name
|
||||||
description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
|
description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||||
imageURL := absoluteURL(r, view.ThumbnailURL)
|
imageURL := socialImageURL(r, box, file, view)
|
||||||
|
imageAlt := fmt.Sprintf("Download card for %s", file.Name)
|
||||||
|
ogType := socialOGType(file)
|
||||||
|
mediaURL := ""
|
||||||
|
if file.PreviewKind == "video" {
|
||||||
|
mediaURL = absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
if locked && box.Obfuscate {
|
if locked && box.Obfuscate {
|
||||||
title = "Protected Warpbox file"
|
title = "Protected Warpbox file"
|
||||||
description = "This shared file is password protected."
|
description = "This shared file is password protected."
|
||||||
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
||||||
|
imageAlt = "Password protected file on Warp Box"
|
||||||
|
ogType = "website"
|
||||||
|
mediaURL = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID))
|
||||||
|
|
||||||
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
CanonicalURL: pageURL,
|
||||||
|
Robots: web.RobotsNone,
|
||||||
|
OGType: ogType,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
|
ImageAlt: imageAlt,
|
||||||
|
ImageType: socialImageType(file),
|
||||||
|
MediaURL: mediaURL,
|
||||||
|
MediaType: file.ContentType,
|
||||||
Data: previewPageData{
|
Data: previewPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
File: view,
|
File: view,
|
||||||
@@ -136,22 +310,41 @@ 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) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
if file.Processing {
|
||||||
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.ProcessingError != "" {
|
||||||
|
a.logger.Warn("failed file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4243, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||||
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
a.logger.Warn("failed box download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4247, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
||||||
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||||
|
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) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -160,9 +353,25 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.servePlaceholderThumbnail(w, r)
|
a.servePlaceholderThumbnail(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||||
|
a.logger.Warn("thumbnail request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4110, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if thumbnail := a.generateMissingThumbnailForRequest(r, box, file); thumbnail != "" {
|
||||||
|
file.Thumbnail = thumbnail
|
||||||
|
object, err = a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||||
|
if err == nil {
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// The thumbnail isn't generated yet (background job pending). Serve the
|
// The thumbnail isn't generated yet (background job pending). Serve the
|
||||||
// placeholder but mark it non-cacheable, otherwise the browser would
|
// placeholder but mark it non-cacheable, otherwise the browser would
|
||||||
// keep showing the placeholder until a hard refresh once the real
|
// keep showing the placeholder until a hard refresh once the real
|
||||||
@@ -177,6 +386,178 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !jobs.NeedsVideoScenes(file) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||||
|
a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" {
|
||||||
|
file.SceneThumbnail = scene
|
||||||
|
object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
||||||
|
if err == nil {
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.servePlaceholderThumbnail(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !jobs.NeedsArchiveListing(file) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||||
|
a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||||
|
http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
if err == nil {
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||||
|
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||||
|
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
|
||||||
|
if err != nil || thumbnail == "" {
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("on-demand thumbnail generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4102, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].ID == file.ID {
|
||||||
|
box.Files[i].Thumbnail = thumbnail
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.uploadService.SaveBox(box); err != nil {
|
||||||
|
a.logger.Warn("on-demand thumbnail metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4103, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||||
|
if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file)
|
||||||
|
if err != nil || scene == "" {
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].ID == file.ID {
|
||||||
|
box.Files[i].SceneThumbnail = scene
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.uploadService.SaveBox(box); err != nil {
|
||||||
|
a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return scene
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||||
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
|
||||||
|
if err != nil || listing == "" {
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].ID == file.ID {
|
||||||
|
box.Files[i].ArchiveListing = listing
|
||||||
|
box.Files[i].ArchiveListingObjectKey = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.uploadService.SaveBox(box); err != nil {
|
||||||
|
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return listing
|
||||||
|
}
|
||||||
|
|
||||||
|
func troubleReasonForLog(box services.Box, file services.File) string {
|
||||||
|
if services.FileHasTrouble(file) {
|
||||||
|
return file.ProcessingError
|
||||||
|
}
|
||||||
|
return services.BoxTroubleReason(box)
|
||||||
|
}
|
||||||
|
|
||||||
// servePlaceholderThumbnail serves the fallback image with no-store so the
|
// servePlaceholderThumbnail serves the fallback image with no-store so the
|
||||||
// browser re-requests on the next load and picks up the real thumbnail as soon
|
// browser re-requests on the next load and picks up the real thumbnail as soon
|
||||||
// as it has been generated.
|
// as it has been generated.
|
||||||
@@ -196,7 +577,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 +590,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,15 +619,18 @@ 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
|
||||||
}
|
}
|
||||||
defer object.Body.Close()
|
defer object.Body.Close()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", file.ContentType)
|
w.Header().Set("Content-Type", file.ContentType)
|
||||||
|
disposition := "inline"
|
||||||
if attachment {
|
if attachment {
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
disposition = "attachment"
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name))
|
||||||
if seeker, ok := object.Body.(io.ReadSeeker); ok {
|
if seeker, ok := object.Body.(io.ReadSeeker); ok {
|
||||||
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
|
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
|
||||||
} else {
|
} else {
|
||||||
@@ -259,6 +646,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contentDisposition(disposition, name string) string {
|
||||||
|
filename := cleanDownloadFilename(name)
|
||||||
|
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanDownloadFilename(name string) string {
|
||||||
|
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
|
||||||
|
clean = filepath.Base(clean)
|
||||||
|
if clean == "" || clean == "." || clean == "/" {
|
||||||
|
return "download"
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
func asciiFilenameFallback(name string) string {
|
||||||
|
var fallback strings.Builder
|
||||||
|
for _, char := range name {
|
||||||
|
switch {
|
||||||
|
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';':
|
||||||
|
fallback.WriteByte('_')
|
||||||
|
case char <= 0x7e:
|
||||||
|
fallback.WriteRune(char)
|
||||||
|
default:
|
||||||
|
fallback.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clean := strings.TrimSpace(fallback.String())
|
||||||
|
if clean == "" {
|
||||||
|
return "download"
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||||
data, err := io.ReadAll(source)
|
data, err := io.ReadAll(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,22 +688,42 @@ 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) {
|
||||||
|
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
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
|
||||||
}
|
}
|
||||||
|
for _, file := range box.Files {
|
||||||
|
if file.Processing {
|
||||||
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file.ProcessingError != "" {
|
||||||
|
a.logger.Warn("zip download blocked by failed file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4244, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||||
|
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
a.logger.Warn("zip download blocked by failed box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4248, "box_id", box.ID, "error", services.BoxTroubleReason(box))...)
|
||||||
|
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
w.Header().Set("Content-Type", "application/zip")
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
|
w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip"))
|
||||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||||
|
|
||||||
if err := a.uploadService.WriteZip(w, box); err != nil {
|
if err := a.uploadService.WriteZip(w, box); err != nil {
|
||||||
@@ -293,21 +733,210 @@ 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,
|
||||||
Size: helpers.FormatBytes(file.Size),
|
Size: helpers.FormatBytes(file.Size),
|
||||||
|
SizeBytes: file.Size,
|
||||||
ContentType: file.ContentType,
|
ContentType: file.ContentType,
|
||||||
PreviewKind: file.PreviewKind,
|
PreviewKind: file.PreviewKind,
|
||||||
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),
|
||||||
|
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
|
||||||
|
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
|
||||||
|
HasThumbnail: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)),
|
||||||
|
HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)),
|
||||||
|
HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)),
|
||||||
|
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,
|
||||||
|
Processing: file.Processing,
|
||||||
|
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
|
||||||
|
Error: troubleReasonForLog(box, file),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +952,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
|
||||||
@@ -333,3 +977,31 @@ func absoluteURL(r *http.Request, path string) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%s://%s%s", scheme, r.Host, path)
|
return fmt.Sprintf("%s://%s%s", scheme, r.Host, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSocialPreviewBot(r *http.Request) bool {
|
||||||
|
agent := strings.ToLower(r.UserAgent())
|
||||||
|
if agent == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
bots := []string{
|
||||||
|
"discordbot",
|
||||||
|
"twitterbot",
|
||||||
|
"facebookexternalhit",
|
||||||
|
"telegrambot",
|
||||||
|
"whatsapp",
|
||||||
|
"slackbot",
|
||||||
|
"linkedinbot",
|
||||||
|
"skypeuripreview",
|
||||||
|
"embedly",
|
||||||
|
"pinterest",
|
||||||
|
"vkshare",
|
||||||
|
"mattermost",
|
||||||
|
"mastodon",
|
||||||
|
}
|
||||||
|
for _, bot := range bots {
|
||||||
|
if strings.Contains(agent, bot) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
152
backend/libs/handlers/icons.go
Normal 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
|
||||||
|
}
|
||||||
54
backend/libs/handlers/icons_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/libs/handlers/logging.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
60
backend/libs/handlers/meta.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RobotsTxt serves /robots.txt dynamically so the Sitemap URL reflects the
|
||||||
|
// configured base URL rather than a hard-coded placeholder.
|
||||||
|
func (a *App) RobotsTxt(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
fmt.Fprintf(w, `User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
# Private routes — do not crawl
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /app/
|
||||||
|
Disallow: /account/
|
||||||
|
Disallow: /d/*/f/*/download
|
||||||
|
Disallow: /d/*/zip
|
||||||
|
Disallow: /d/*/thumb/
|
||||||
|
Disallow: /d/*/scene/
|
||||||
|
Disallow: /d/*/archive/
|
||||||
|
Disallow: /d/*/og-image.jpg
|
||||||
|
Disallow: /d/*/unlock
|
||||||
|
Disallow: /d/*/manage/
|
||||||
|
|
||||||
|
Sitemap: %s/sitemap.xml
|
||||||
|
`, strings.TrimRight(siteBaseURL(r, a.cfg.BaseURL), "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SitemapXML serves a minimal /sitemap.xml containing only the public,
|
||||||
|
// indexable homepage. Box/file pages are noindex and deliberately excluded.
|
||||||
|
func (a *App) SitemapXML(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
baseURL := strings.TrimRight(siteBaseURL(r, a.cfg.BaseURL), "/")
|
||||||
|
lastMod := time.Now().UTC().Format("2006-01-02")
|
||||||
|
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>%s/</loc>
|
||||||
|
<lastmod>%s</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
`, baseURL, lastMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
func siteBaseURL(r *http.Request, configured string) string {
|
||||||
|
if configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return absoluteURL(r, "/")
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
@@ -11,10 +13,19 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Open Graph image dimensions recommended for large summary cards
|
// Open Graph image dimensions recommended for large summary cards
|
||||||
@@ -74,6 +85,77 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.serveOGImage(w, r, renderCollage(thumbs))
|
a.serveOGImage(w, r, renderCollage(thumbs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileOGImage renders a branded card for files that should not be served as raw
|
||||||
|
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
|
||||||
|
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if jobs.NeedsArchiveListing(file) {
|
||||||
|
if listing, ok := a.archiveListingForOG(r, box, file); ok {
|
||||||
|
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icon := a.ogFileIcon(file)
|
||||||
|
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ogArchiveListing struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FileCount int `json:"fileCount"`
|
||||||
|
FolderCount int `json:"folderCount"`
|
||||||
|
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||||
|
Root *ogArchiveNode `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ogArchiveNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Dir bool `json:"dir"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Items []*ogArchiveNode `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
|
||||||
|
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||||
|
file.ArchiveListing = listing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return ogArchiveListing{}, false
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
var listing ogArchiveListing
|
||||||
|
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
|
||||||
|
return ogArchiveListing{}, false
|
||||||
|
}
|
||||||
|
if listing.Root == nil {
|
||||||
|
return ogArchiveListing{}, false
|
||||||
|
}
|
||||||
|
return listing, true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||||
@@ -115,6 +197,326 @@ func (a *App) ogPlaceholder() image.Image {
|
|||||||
return canvas
|
return canvas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ogFileIcon(file services.File) image.Image {
|
||||||
|
if a.fileIcons == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
icon := a.fileIcons.lookup(file.Name, file.ContentType)
|
||||||
|
if icon.Retro == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
panel := image.Rect(70, 72, ogImageWidth-70, ogImageHeight-72)
|
||||||
|
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
titleFace := a.ogFont(44, true)
|
||||||
|
bodyFace := a.ogFont(28, false)
|
||||||
|
metaFace := a.ogFont(24, false)
|
||||||
|
buttonFace := a.ogFont(26, true)
|
||||||
|
|
||||||
|
if icon != nil {
|
||||||
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
|
||||||
|
} else {
|
||||||
|
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLines := wrapOGText(file.Name, titleFace, 850)
|
||||||
|
if len(titleLines) > 2 {
|
||||||
|
titleLines = titleLines[:2]
|
||||||
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
|
||||||
|
}
|
||||||
|
y := 156
|
||||||
|
for _, line := range titleLines {
|
||||||
|
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
y += 52
|
||||||
|
}
|
||||||
|
|
||||||
|
size := helpers.FormatBytes(file.Size)
|
||||||
|
typeLabel := strings.TrimSpace(file.ContentType)
|
||||||
|
if typeLabel == "" {
|
||||||
|
typeLabel = "application/octet-stream"
|
||||||
|
}
|
||||||
|
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
|
||||||
|
info := fileCardInfo(file)
|
||||||
|
for i, line := range wrapOGText(info, metaFace, 900) {
|
||||||
|
if i >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
button := image.Rect(110, 474, 430, 548)
|
||||||
|
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||||
|
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
|
||||||
|
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
titleFace := a.ogFont(36, true)
|
||||||
|
bodyFace := a.ogFont(22, false)
|
||||||
|
treeFace := a.ogFont(19, false)
|
||||||
|
labelFace := a.ogFont(17, true)
|
||||||
|
|
||||||
|
icon := a.ogFileIcon(file)
|
||||||
|
if icon != nil {
|
||||||
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
|
||||||
|
} else {
|
||||||
|
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := listing.Name
|
||||||
|
if strings.TrimSpace(title) == "" {
|
||||||
|
title = file.Name
|
||||||
|
}
|
||||||
|
titleLines := wrapOGText(title, titleFace, 820)
|
||||||
|
if len(titleLines) > 2 {
|
||||||
|
titleLines = titleLines[:2]
|
||||||
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
|
||||||
|
}
|
||||||
|
y := 106
|
||||||
|
for _, line := range titleLines {
|
||||||
|
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
y += 42
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
|
||||||
|
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
|
||||||
|
treePanel := image.Rect(104, 214, 1096, 548)
|
||||||
|
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||||
|
|
||||||
|
rows := archiveOGRows(listing.Root, 13)
|
||||||
|
rowY := treePanel.Min.Y + 64
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Ellipsis {
|
||||||
|
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
x := treePanel.Min.X + 20 + row.Depth*28
|
||||||
|
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
|
||||||
|
name := row.Name
|
||||||
|
if row.Dir {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
maxNameWidth := treePanel.Max.X - x - 170
|
||||||
|
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
|
||||||
|
if !row.Dir {
|
||||||
|
size := formatOGArchiveBytes(row.Size)
|
||||||
|
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||||
|
}
|
||||||
|
rowY += 23
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveOGRow struct {
|
||||||
|
Name string
|
||||||
|
Icon string
|
||||||
|
Size uint64
|
||||||
|
Dir bool
|
||||||
|
Depth int
|
||||||
|
Ellipsis bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
|
||||||
|
rows := make([]archiveOGRow, 0, limit+1)
|
||||||
|
truncated := false
|
||||||
|
var walk func(items []*ogArchiveNode, depth int)
|
||||||
|
walk = func(items []*ogArchiveNode, depth int) {
|
||||||
|
for _, item := range items {
|
||||||
|
if len(rows) >= limit {
|
||||||
|
truncated = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
icon := item.Icon
|
||||||
|
if item.Dir {
|
||||||
|
icon = "folder"
|
||||||
|
}
|
||||||
|
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
|
||||||
|
if item.Dir {
|
||||||
|
walk(item.Items, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if root != nil {
|
||||||
|
walk(root.Items, 0)
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
rows = append(rows, archiveOGRow{Ellipsis: true})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
|
||||||
|
c := archiveOGIconColor(icon)
|
||||||
|
rect := image.Rect(x, y, x+20, y+20)
|
||||||
|
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
if icon == "folder" {
|
||||||
|
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveOGIconColor(icon string) color.RGBA {
|
||||||
|
switch icon {
|
||||||
|
case "folder":
|
||||||
|
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
|
||||||
|
case "img":
|
||||||
|
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
|
||||||
|
case "vid":
|
||||||
|
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
|
||||||
|
case "aud":
|
||||||
|
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
|
||||||
|
case "code":
|
||||||
|
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
|
||||||
|
case "arc":
|
||||||
|
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
|
||||||
|
default:
|
||||||
|
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveOGTextColor(row archiveOGRow) color.RGBA {
|
||||||
|
if row.Dir {
|
||||||
|
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
|
||||||
|
}
|
||||||
|
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
|
||||||
|
if strings.TrimSpace(listing.Type) != "" {
|
||||||
|
return listing.Type
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(file.ContentType) != "" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "Archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOGArchiveBytes(size uint64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
value := float64(size) / unit
|
||||||
|
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
||||||
|
if value < unit {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||||
|
}
|
||||||
|
value /= unit
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f PiB", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileCardInfo(file services.File) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(file.ContentType, "audio/"):
|
||||||
|
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
|
||||||
|
case file.ContentType == "text/markdown":
|
||||||
|
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
|
||||||
|
case strings.Contains(file.ContentType, "html"):
|
||||||
|
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
|
||||||
|
case strings.Contains(file.ContentType, "pdf"):
|
||||||
|
return "PDF file shared through Warpbox. Open the link to download the original file."
|
||||||
|
case strings.HasPrefix(file.ContentType, "text/"):
|
||||||
|
return "Text file shared through Warpbox. Open the link to preview the content or download."
|
||||||
|
default:
|
||||||
|
return "File shared through Warpbox. Open the link to preview available details or download the original."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ogFont(size float64, bold bool) font.Face {
|
||||||
|
name := "PixeloidSans.ttf"
|
||||||
|
if bold {
|
||||||
|
name = "PixeloidSans-Bold.ttf"
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
parsed, err := opentype.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
return face
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapOGText(text string, face font.Face, maxWidth int) []string {
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := words[0]
|
||||||
|
for _, word := range words[1:] {
|
||||||
|
next := current + " " + word
|
||||||
|
if ogTextWidth(face, next) <= maxWidth {
|
||||||
|
current = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, current)
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
lines = append(lines, current)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimOGText(text string, face font.Face, maxWidth int) string {
|
||||||
|
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
|
||||||
|
text = text[:len(text)-1]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func ogTextWidth(face font.Face, text string) int {
|
||||||
|
bounds, _ := font.BoundString(face, text)
|
||||||
|
return (bounds.Max.X - bounds.Min.X).Ceil()
|
||||||
|
}
|
||||||
|
|
||||||
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
||||||
func renderCollage(thumbs []image.Image) image.Image {
|
func renderCollage(thumbs []image.Image) image.Image {
|
||||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
type homeData struct {
|
type homeData struct {
|
||||||
MaxUploadSize string
|
MaxUploadSize string
|
||||||
|
MaxUploadBytes int64
|
||||||
LimitSummary string
|
LimitSummary string
|
||||||
Collections []collectionView
|
Collections []collectionView
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
@@ -46,14 +47,29 @@ 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
|
||||||
}
|
}
|
||||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
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, maxUploadBytes, 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{
|
||||||
Title: "Upload your files",
|
Title: "Upload your files",
|
||||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
Description: "Upload and share files fast. Drop a file, get a link — private, temporary transfers that expire on your terms.",
|
||||||
|
CanonicalURL: absoluteURL(r, "/"),
|
||||||
|
ImageURL: absoluteURL(r, "/static/og-default.png"),
|
||||||
|
ImageAlt: "Warp Box — simple file sharing and fast downloads",
|
||||||
CurrentUser: currentUser,
|
CurrentUser: currentUser,
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
MaxUploadSize: maxUploadSize,
|
MaxUploadSize: maxUploadSize,
|
||||||
|
MaxUploadBytes: maxUploadBytes,
|
||||||
LimitSummary: limitSummary,
|
LimitSummary: limitSummary,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
@@ -75,12 +91,16 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
|
func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) {
|
||||||
ladder := []int{60, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
|
ladder := []int{60, 360, 720, 1440, 2880, 4320, 7200, 10080, 14400, 20160, 43200, 86400, 129600, 259200, 525600}
|
||||||
|
|
||||||
capMinutes := maxDays * 24 * 60
|
capMinutes := maxDays * 24 * 60
|
||||||
if unlimited || capMinutes <= 0 {
|
if unlimited || capMinutes <= 0 {
|
||||||
@@ -103,6 +123,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
|
||||||
@@ -133,26 +157,33 @@ func expiryLabel(minutes int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
|
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, int64, string) {
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
return "No file size limit", "Admin uploads bypass storage and daily caps."
|
return "No file size limit", -1, "Admin uploads bypass storage and daily caps."
|
||||||
}
|
}
|
||||||
if !loggedIn {
|
if !loggedIn {
|
||||||
if !settings.AnonymousUploadsEnabled {
|
if !settings.AnonymousUploadsEnabled {
|
||||||
return "Anonymous uploads disabled", "Sign in to upload files."
|
return "Anonymous uploads disabled", 0, "Sign in to upload files."
|
||||||
}
|
}
|
||||||
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
|
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), services.MegabytesToBytes(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
|
||||||
}
|
}
|
||||||
policy := a.settingsService.EffectivePolicyForUser(settings, user)
|
policy := a.settingsService.EffectivePolicyForUser(settings, user)
|
||||||
maxUpload := a.uploadService.MaxUploadSizeLabel()
|
maxUpload := a.uploadService.MaxUploadSizeLabel()
|
||||||
|
maxUploadBytes := a.uploadService.MaxUploadSize()
|
||||||
if policy.MaxUploadMB < 0 {
|
if policy.MaxUploadMB < 0 {
|
||||||
maxUpload = "unlimited"
|
maxUpload = "unlimited"
|
||||||
|
maxUploadBytes = -1
|
||||||
} else if policy.MaxUploadMB > 0 {
|
} else if policy.MaxUploadMB > 0 {
|
||||||
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
|
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
|
||||||
|
maxUploadBytes = services.MegabytesToBytes(policy.MaxUploadMB)
|
||||||
}
|
}
|
||||||
quota := "unlimited"
|
quota := "unlimited"
|
||||||
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, maxUploadBytes, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
|
||||||
}
|
}
|
||||||
|
|||||||
438
backend/libs/handlers/resumable.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type resumableCreateRequest struct {
|
||||||
|
Files []services.ResumableFileInput `json:"files"`
|
||||||
|
MaxDays int `json:"maxDays"`
|
||||||
|
ExpiresMinutes int `json:"expiresMinutes"`
|
||||||
|
MaxDownloads int `json:"maxDownloads"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ObfuscateMetadata bool `json:"obfuscateMetadata"`
|
||||||
|
CollectionID string `json:"collectionId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resumableSessionResponse struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ResumeToken string `json:"resumeToken,omitempty"`
|
||||||
|
ChunkSize int64 `json:"chunkSize"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
BoxID string `json:"boxId,omitempty"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
Files []services.ResumableFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CreateResumableUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||||
|
if authErr != nil {
|
||||||
|
a.logger.Warn("resumable upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4011)...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||||
|
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !settings.ResumableUploadsEnabled {
|
||||||
|
helpers.WriteJSONError(w, http.StatusForbidden, "resumable uploads are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
||||||
|
a.logger.Warn("resumable anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4013)...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rateKey := uploadRateKey(r, user, loggedIn)
|
||||||
|
if !isAdminUpload && policy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, policy.ShortRequests, policy.ShortWindow, time.Now().UTC()) {
|
||||||
|
a.logger.Warn("resumable upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4291, "user_id", user.ID)...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload resumableCreateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload session request could not be read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileSizes := make([]int64, 0, len(payload.Files))
|
||||||
|
var totalBytes int64
|
||||||
|
for _, file := range payload.Files {
|
||||||
|
fileSizes = append(fileSizes, file.Size)
|
||||||
|
totalBytes += file.Size
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
|
||||||
|
a.logger.Warn("resumable upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(payload.Files))...)
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
|
||||||
|
a.logger.Warn("resumable upload rejected by box policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(payload.Files))...)
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts, err := a.resumableUploadOptions(r, payload, user, loggedIn, isAdminUpload, policy)
|
||||||
|
if err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunkSize := int64(settings.ResumableChunkSizeMB * 1024 * 1024)
|
||||||
|
retention := time.Duration(settings.ResumableRetentionHours) * time.Hour
|
||||||
|
session, err := a.uploadService.CreateResumableSession(payload.Files, opts, chunkSize, retention, resumableChunkRoot(settings))
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable session create failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4002, "user_id", user.ID, "error", err.Error())...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("resumable upload session created", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2002, "user_id", user.ID, "session_id", session.ID, "files", len(session.Files), "bytes", totalBytes, "anonymous", !loggedIn)...)
|
||||||
|
helpers.WriteJSON(w, http.StatusCreated, resumableResponse(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ResumableUploadStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := a.authorizedResumableSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helpers.WriteJSON(w, http.StatusOK, resumableResponse(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) AddResumableFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := a.authorizedResumableSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, loggedIn, _ := a.currentUserWithAuthError(r)
|
||||||
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||||
|
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Files []services.ResumableFileInput `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, "upload files request could not be read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileSizes := make([]int64, 0, len(session.Files)+len(payload.Files))
|
||||||
|
var totalBytes int64
|
||||||
|
for _, file := range session.Files {
|
||||||
|
fileSizes = append(fileSizes, file.Size)
|
||||||
|
totalBytes += file.Size
|
||||||
|
}
|
||||||
|
for _, file := range payload.Files {
|
||||||
|
fileSizes = append(fileSizes, file.Size)
|
||||||
|
totalBytes += file.Size
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated, err := a.uploadService.AddResumableFiles(session.ID, payload.Files)
|
||||||
|
if err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("resumable upload files added", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2006, "session_id", session.ID, "added", len(updated.Files)-len(session.Files), "files", len(updated.Files))...)
|
||||||
|
helpers.WriteJSON(w, http.StatusOK, resumableResponse(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) PutResumableChunk(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := a.authorizedResumableSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileID := r.PathValue("fileID")
|
||||||
|
index, err := strconv.Atoi(r.PathValue("index"))
|
||||||
|
if err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, "chunk index is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := a.uploadService.PutResumableChunk(r.Context(), session.ID, fileID, index, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable chunk failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4003, "session_id", session.ID, "file_id", fileID, "chunk", index, "error", err.Error())...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("resumable chunk uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2003, "session_id", session.ID, "file_id", fileID, "chunk", index)...)
|
||||||
|
helpers.WriteJSON(w, http.StatusOK, resumableResponse(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := a.authorizedResumableSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Status == services.ResumableStatusCompleted {
|
||||||
|
result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files))...)
|
||||||
|
helpers.WriteJSON(w, http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Status == services.ResumableStatusProcessing {
|
||||||
|
result, err := a.uploadService.FinalizeProcessingResumableSession(r.Context(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", session.ID, "box_id", result.BoxID, "files", len(result.Files))...)
|
||||||
|
helpers.WriteJSON(w, http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, loggedIn, _ := a.currentUserWithAuthError(r)
|
||||||
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||||
|
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileSizes := make([]int64, 0, len(session.Files))
|
||||||
|
var totalBytes int64
|
||||||
|
for _, file := range session.Files {
|
||||||
|
fileSizes = append(fileSizes, file.Size)
|
||||||
|
totalBytes += file.Size
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, message := a.checkStorageBackendCapacity(session.Options.StorageBackendID, settings, totalBytes); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, completed, err := a.uploadService.CreateProcessingBoxFromResumable(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable upload complete failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
|
||||||
|
a.logger.Warn("failed to record resumable upload usage", "source", "quota", "severity", "warn", "code", 4404, "error", err.Error())
|
||||||
|
}
|
||||||
|
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
||||||
|
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4405, "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.finalizeResumableUploadAsync(completed.ID, result.BoxID)
|
||||||
|
a.logger.Info("resumable upload queued for processing", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "user_id", user.ID, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
|
||||||
|
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CompleteUploadedResumableUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := a.authorizedResumableSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, loggedIn, _ := a.currentUserWithAuthError(r)
|
||||||
|
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||||
|
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileSizes := make([]int64, 0, len(session.Files))
|
||||||
|
var totalBytes int64
|
||||||
|
var completeCount int
|
||||||
|
for _, file := range session.Files {
|
||||||
|
if len(file.UploadedChunks) != file.ChunkCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fileSizes = append(fileSizes, file.Size)
|
||||||
|
totalBytes += file.Size
|
||||||
|
completeCount++
|
||||||
|
}
|
||||||
|
if completeCount == 0 {
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, "no fully uploaded files to finish")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status, message := a.checkStorageBackendCapacity(session.Options.StorageBackendID, settings, totalBytes); message != "" {
|
||||||
|
helpers.WriteJSONError(w, status, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, completed, err := a.uploadService.CompleteUploadedResumableSession(r.Context(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable partial complete failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4005, "session_id", session.ID, "error", err.Error())...)
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isAdminUpload {
|
||||||
|
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
|
||||||
|
a.logger.Warn("failed to record partial resumable upload usage", "source", "quota", "severity", "warn", "code", 4406, "error", err.Error())
|
||||||
|
}
|
||||||
|
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
||||||
|
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4405, "error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||||
|
a.logger.Info("resumable uploaded files completed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2007, "user_id", user.ID, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
|
||||||
|
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) finalizeResumableUploadAsync(sessionID, boxID string) {
|
||||||
|
go func() {
|
||||||
|
a.logger.Info("resumable upload processing started", "source", "user-upload", "severity", "user_activity", "code", 2009, "session_id", sessionID, "box_id", boxID)
|
||||||
|
result, err := a.uploadService.FinalizeProcessingResumableSession(context.Background(), sessionID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("resumable upload processing failed", "source", "user-upload", "severity", "warn", "code", 4010, "session_id", sessionID, "box_id", boxID, "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||||
|
a.logger.Info("resumable upload processing completed", "source", "user-upload", "severity", "user_activity", "code", 2010, "session_id", sessionID, "box_id", result.BoxID, "files", len(result.Files))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableChunkRoot(settings services.UploadPolicySettings) string {
|
||||||
|
if settings.ResumableChunkMode != "custom" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(settings.ResumableChunkPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) CancelResumableUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, ok := a.authorizedResumableSession(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.uploadService.CancelResumableSession(session.ID); err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.logger.Info("resumable upload cancelled", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2005, "session_id", session.ID)...)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) authorizedResumableSession(w http.ResponseWriter, r *http.Request) (services.ResumableSession, bool) {
|
||||||
|
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||||
|
if authErr != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||||
|
return services.ResumableSession{}, false
|
||||||
|
}
|
||||||
|
session, err := a.uploadService.GetResumableSession(r.PathValue("sessionID"))
|
||||||
|
if err != nil {
|
||||||
|
helpers.WriteJSONError(w, http.StatusNotFound, "upload session not found")
|
||||||
|
return services.ResumableSession{}, false
|
||||||
|
}
|
||||||
|
if !a.uploadService.VerifyResumableToken(session, r.Header.Get("X-Warpbox-Resume-Token")) {
|
||||||
|
helpers.WriteJSONError(w, http.StatusUnauthorized, "upload session not found")
|
||||||
|
return services.ResumableSession{}, false
|
||||||
|
}
|
||||||
|
if loggedIn {
|
||||||
|
if session.Options.OwnerID != user.ID {
|
||||||
|
helpers.WriteJSONError(w, http.StatusForbidden, "upload session not found")
|
||||||
|
return services.ResumableSession{}, false
|
||||||
|
}
|
||||||
|
return session, true
|
||||||
|
}
|
||||||
|
if session.Options.OwnerID != "" || session.Options.CreatorIP != uploadClientIP(r) {
|
||||||
|
helpers.WriteJSONError(w, http.StatusForbidden, "upload session not found")
|
||||||
|
return services.ResumableSession{}, false
|
||||||
|
}
|
||||||
|
return session, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadUploadPolicyForAPI(w http.ResponseWriter, r *http.Request, user services.User, loggedIn bool) (services.UploadPolicySettings, services.EffectiveUploadPolicy, bool) {
|
||||||
|
settings, err := a.settingsService.UploadPolicy()
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5006, "error", err.Error())
|
||||||
|
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
|
||||||
|
return services.UploadPolicySettings{}, services.EffectiveUploadPolicy{}, false
|
||||||
|
}
|
||||||
|
return settings, a.effectiveUploadPolicy(settings, user, loggedIn), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) resumableUploadOptions(r *http.Request, payload resumableCreateRequest, user services.User, loggedIn, isAdminUpload bool, policy services.EffectiveUploadPolicy) (services.UploadOptions, error) {
|
||||||
|
var ownerID string
|
||||||
|
var collectionID string
|
||||||
|
if loggedIn {
|
||||||
|
ownerID = user.ID
|
||||||
|
collectionID = strings.TrimSpace(payload.CollectionID)
|
||||||
|
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||||
|
return services.UploadOptions{}, fmt.Errorf("collection not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unlimitedExpiry := isAdminUpload || policy.MaxDays < 0
|
||||||
|
rawMaxDays := payload.MaxDays
|
||||||
|
maxDays := rawMaxDays
|
||||||
|
if maxDays <= 0 {
|
||||||
|
maxDays = 7
|
||||||
|
if policy.MaxDays > 0 && policy.MaxDays < maxDays {
|
||||||
|
maxDays = policy.MaxDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expiresMinutes := payload.ExpiresMinutes
|
||||||
|
if expiresMinutes < 0 || rawMaxDays < 0 {
|
||||||
|
if !unlimitedExpiry {
|
||||||
|
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
|
||||||
|
}
|
||||||
|
expiresMinutes = -1
|
||||||
|
} else if !unlimitedExpiry {
|
||||||
|
if maxDays > policy.MaxDays {
|
||||||
|
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
|
||||||
|
}
|
||||||
|
if expiresMinutes > 0 && expiresMinutes > policy.MaxDays*24*60 {
|
||||||
|
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return services.UploadOptions{
|
||||||
|
MaxDays: maxDays,
|
||||||
|
ExpiresInMinutes: expiresMinutes,
|
||||||
|
MaxDownloads: payload.MaxDownloads,
|
||||||
|
Password: payload.Password,
|
||||||
|
ObfuscateMetadata: payload.ObfuscateMetadata,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
CollectionID: collectionID,
|
||||||
|
SkipSizeLimit: isAdminUpload || policy.MaxUploadMB < 0,
|
||||||
|
CreatorIP: uploadClientIP(r),
|
||||||
|
StorageBackendID: policy.StorageBackendID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableResponse(session services.ResumableSession) resumableSessionResponse {
|
||||||
|
return resumableSessionResponse{
|
||||||
|
SessionID: session.ID,
|
||||||
|
ResumeToken: session.ResumeToken,
|
||||||
|
ChunkSize: session.ChunkSize,
|
||||||
|
Status: session.Status,
|
||||||
|
BoxID: session.BoxID,
|
||||||
|
ExpiresAt: session.ExpiresAt.Format(time.RFC3339),
|
||||||
|
Files: session.Files,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -15,6 +16,35 @@ 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 (a *App) ServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.Header().Set("Service-Worker-Allowed", "/")
|
||||||
|
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "js", "service-worker.js"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ShareTargetFallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/?share-target=unsupported", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,3 +28,76 @@ func TestSetStaticCacheHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebManifestIncludesShareTarget(t *testing.T) {
|
||||||
|
data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile returned error: %v", err)
|
||||||
|
}
|
||||||
|
var manifest struct {
|
||||||
|
ShareTarget struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
EncType string `json:"enctype"`
|
||||||
|
Params struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Files []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Accept []string `json:"accept"`
|
||||||
|
} `json:"files"`
|
||||||
|
} `json:"params"`
|
||||||
|
} `json:"share_target"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.ShareTarget.Action != "/share-target" || manifest.ShareTarget.Method != "POST" || manifest.ShareTarget.EncType != "multipart/form-data" {
|
||||||
|
t.Fatalf("unexpected share_target config: %+v", manifest.ShareTarget)
|
||||||
|
}
|
||||||
|
if manifest.ShareTarget.Params.Title != "title" || manifest.ShareTarget.Params.Text != "text" || manifest.ShareTarget.Params.URL != "url" {
|
||||||
|
t.Fatalf("unexpected share_target params: %+v", manifest.ShareTarget.Params)
|
||||||
|
}
|
||||||
|
if len(manifest.ShareTarget.Params.Files) != 1 || manifest.ShareTarget.Params.Files[0].Name != "files" || len(manifest.ShareTarget.Params.Files[0].Accept) != 1 || manifest.ShareTarget.Params.Files[0].Accept[0] != "*/*" {
|
||||||
|
t.Fatalf("unexpected share_target files: %+v", manifest.ShareTarget.Params.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceWorkerServedFromRootScope(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/service-worker.js", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.ServiceWorker(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if got := response.Header().Get("Service-Worker-Allowed"); got != "/" {
|
||||||
|
t.Fatalf("Service-Worker-Allowed = %q, want /", got)
|
||||||
|
}
|
||||||
|
if got := response.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" {
|
||||||
|
t.Fatalf("Content-Type = %q", got)
|
||||||
|
}
|
||||||
|
if response.Body.Len() == 0 {
|
||||||
|
t.Fatalf("service worker body missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShareTargetFallbackRedirectsHome(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/share-target", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.ShareTargetFallback(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
if got := response.Header().Get("Location"); got != "/?share-target=unsupported" {
|
||||||
|
t.Fatalf("Location = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,12 @@ 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())...)
|
||||||
|
var maxBytesErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxBytesErr) {
|
||||||
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, "upload exceeds the configured upload limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
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 +70,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 +125,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 +146,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,16 +158,98 @@ 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, ""
|
||||||
}
|
}
|
||||||
|
sizes := make([]int64, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
sizes = append(sizes, file.Size)
|
||||||
|
}
|
||||||
|
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, fileSizes []int64, totalBytes int64) (int, string) {
|
||||||
|
if len(fileSizes) == 0 {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if policy.MaxUploadMB > 0 {
|
if policy.MaxUploadMB > 0 {
|
||||||
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
|
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
|
||||||
for _, file := range files {
|
for _, fileSize := range fileSizes {
|
||||||
if file.Size > maxBytes {
|
if fileSize > maxBytes {
|
||||||
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
|
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,16 +261,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 +274,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 +287,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 +370,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 {
|
||||||
|
|||||||
76
backend/libs/handlers/upload_group.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uploadGroupWindow is how long after a batched upload a follow-up upload with
|
||||||
|
// the same X-Warpbox-Batch value (and same account/IP) is folded into the same
|
||||||
|
// box. ShareX sends a multi-file selection as separate back-to-back requests;
|
||||||
|
// the batch header lets it land them in one box.
|
||||||
|
const uploadGroupWindow = 20 * time.Second
|
||||||
|
|
||||||
|
// uploadBatchHeader is the opt-in request header. Without it, uploads behave
|
||||||
|
// exactly as before (one box per request). With it, requests sharing the same
|
||||||
|
// value (per account/IP) within uploadGroupWindow are grouped into one box.
|
||||||
|
const uploadBatchHeader = "X-Warpbox-Batch"
|
||||||
|
|
||||||
|
// uploadGroupPruneInterval is how often entryFor drops stale entries so the map
|
||||||
|
// can't grow without bound (one key per account/IP + batch value otherwise).
|
||||||
|
const uploadGroupPruneInterval = 5 * time.Minute
|
||||||
|
|
||||||
|
// uploadGrouper tracks the most recent box per batch key so opt-in batched
|
||||||
|
// uploads land in a single box. Each key has its own lock, which also serialises
|
||||||
|
// that key's concurrent uploads so they append to the same box instead of racing
|
||||||
|
// to create several.
|
||||||
|
type uploadGrouper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*uploadGroupEntry
|
||||||
|
lastPrune time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadGroupEntry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
boxID string
|
||||||
|
manageURL string
|
||||||
|
deleteURL string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUploadGrouper() *uploadGrouper {
|
||||||
|
return &uploadGrouper{entries: make(map[string]*uploadGroupEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *uploadGrouper) entryFor(key string) *uploadGroupEntry {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.pruneLocked(time.Now())
|
||||||
|
entry, ok := g.entries[key]
|
||||||
|
if !ok {
|
||||||
|
entry = &uploadGroupEntry{at: time.Now()}
|
||||||
|
g.entries[key] = entry
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocked drops entries whose last use is well past the grouping window so
|
||||||
|
// the map stays bounded to recently-active keys. Callers must hold g.mu. Entries
|
||||||
|
// currently in use are kept to avoid removing one a request is about to
|
||||||
|
// populate.
|
||||||
|
func (g *uploadGrouper) pruneLocked(now time.Time) {
|
||||||
|
if now.Sub(g.lastPrune) < uploadGroupPruneInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.lastPrune = now
|
||||||
|
for key, entry := range g.entries {
|
||||||
|
if !entry.mu.TryLock() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stale := now.Sub(entry.at) > 2*uploadGroupWindow
|
||||||
|
entry.mu.Unlock()
|
||||||
|
if stale {
|
||||||
|
delete(g.entries, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/libs/handlers/upload_group_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUploadGroupPrunesFailedEntries(t *testing.T) {
|
||||||
|
g := newUploadGrouper()
|
||||||
|
entry := g.entryFor("ip:203.0.113.1|failed")
|
||||||
|
entry.mu.Lock()
|
||||||
|
entry.at = time.Now().Add(-3 * uploadGroupWindow)
|
||||||
|
entry.mu.Unlock()
|
||||||
|
g.lastPrune = time.Now().Add(-uploadGroupPruneInterval)
|
||||||
|
|
||||||
|
_ = g.entryFor("ip:203.0.113.1|next")
|
||||||
|
|
||||||
|
if _, ok := g.entries["ip:203.0.113.1|failed"]; ok {
|
||||||
|
t.Fatalf("stale failed entry was not pruned")
|
||||||
|
}
|
||||||
|
if _, ok := g.entries["ip:203.0.113.1|next"]; !ok {
|
||||||
|
t.Fatalf("new entry was not created")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -10,8 +11,10 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -46,6 +49,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()
|
||||||
@@ -67,6 +106,545 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
|
||||||
|
request.Header.Set("User-Agent", "Discordbot/2.0")
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadPage(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
|
||||||
|
t.Fatalf("download page did not render text thumbnail image: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||||
|
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
request.Header.Set("User-Agent", "TelegramBot")
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
|
||||||
|
t.Fatalf("social preview body missing twitter card metadata: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
uploadResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(uploadResponse, request)
|
||||||
|
if uploadResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
|
||||||
|
previewRequest.SetPathValue("boxID", payload.BoxID)
|
||||||
|
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, previewRequest)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(response.Body.String(), "preview-title") {
|
||||||
|
t.Fatalf("image social preview bot received HTML preview page")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
|
||||||
|
t.Fatalf("image social preview body = %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-size-bytes="5"`,
|
||||||
|
`data-source-url="/d/` + payload.BoxID,
|
||||||
|
`data-download-url="/d/` + payload.BoxID,
|
||||||
|
`data-icon-url="/static/file-icons/`,
|
||||||
|
`data-preview-tabs`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("preview page missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadPageShowsProcessingFailure(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
box.Files[0].Processing = false
|
||||||
|
box.Files[0].ProcessingError = "Access Denied."
|
||||||
|
if err := app.uploadService.SaveBox(box); err != nil {
|
||||||
|
t.Fatalf("SaveBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadPage(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"Upload processing failed",
|
||||||
|
"Access Denied.",
|
||||||
|
"is-failed",
|
||||||
|
"Failed",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("download page missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) {
|
||||||
|
t.Fatalf("failed file still exposed download context: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileDownloadUsesOriginalFilename(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFileContent(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
disposition := response.Header().Get("Content-Disposition")
|
||||||
|
for _, want := range []string{
|
||||||
|
`attachment;`,
|
||||||
|
`filename="report final.txt"`,
|
||||||
|
`filename*=UTF-8''report%20final.txt`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(disposition, want) {
|
||||||
|
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if response.Body.String() != "hello" {
|
||||||
|
t.Fatalf("body = %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello")
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFileContent(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
disposition := response.Header().Get("Content-Disposition")
|
||||||
|
for _, want := range []string{
|
||||||
|
`inline;`,
|
||||||
|
`filename="r_sum_ 2026.txt"`,
|
||||||
|
`filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(disposition, want) {
|
||||||
|
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createBody := `{"files":[{"name":"note.txt","size":11,"contentType":"text/plain"}],"expiresMinutes":60}`
|
||||||
|
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
|
||||||
|
createRequest.Header.Set("Accept", "application/json")
|
||||||
|
createResponse := httptest.NewRecorder()
|
||||||
|
app.CreateResumableUpload(createResponse, createRequest)
|
||||||
|
if createResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
|
||||||
|
}
|
||||||
|
var session struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ResumeToken string `json:"resumeToken"`
|
||||||
|
ChunkSize int64 `json:"chunkSize"`
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ChunkCount int `json:"chunkCount"`
|
||||||
|
UploadedChunks []int `json:"uploadedChunks"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal session returned error: %v", err)
|
||||||
|
}
|
||||||
|
if session.SessionID == "" || session.ResumeToken == "" || session.ChunkSize != 4 || len(session.Files) != 1 || session.Files[0].ChunkCount != 3 {
|
||||||
|
t.Fatalf("unexpected session response: %+v", session)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := map[int]string{1: "o wo", 0: "hell", 2: "rld"}
|
||||||
|
for index, body := range chunks {
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/"+strconv.Itoa(index), strings.NewReader(body))
|
||||||
|
request.SetPathValue("sessionID", session.SessionID)
|
||||||
|
request.SetPathValue("fileID", session.Files[0].ID)
|
||||||
|
request.SetPathValue("index", strconv.Itoa(index))
|
||||||
|
request.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.PutResumableChunk(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("chunk %d status = %d, body = %s", index, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
|
||||||
|
completeRequest.SetPathValue("sessionID", session.SessionID)
|
||||||
|
completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
completeResponse := httptest.NewRecorder()
|
||||||
|
app.CompleteResumableUpload(completeResponse, completeRequest)
|
||||||
|
if completeResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal result returned error: %v", err)
|
||||||
|
}
|
||||||
|
replayRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
|
||||||
|
replayRequest.SetPathValue("sessionID", session.SessionID)
|
||||||
|
replayRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
replayResponse := httptest.NewRecorder()
|
||||||
|
app.CompleteResumableUpload(replayResponse, replayRequest)
|
||||||
|
if replayResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("complete replay status = %d, body = %s", replayResponse.Code, replayResponse.Body.String())
|
||||||
|
}
|
||||||
|
var replayPayload services.UploadResult
|
||||||
|
if err := json.Unmarshal(replayResponse.Body.Bytes(), &replayPayload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal replay result returned error: %v", err)
|
||||||
|
}
|
||||||
|
if replayPayload.BoxID != payload.BoxID || replayPayload.BoxURL == "" {
|
||||||
|
t.Fatalf("unexpected replay result: %+v, original: %+v", replayPayload, payload)
|
||||||
|
}
|
||||||
|
box := waitForProcessedBox(t, app, payload.BoxID)
|
||||||
|
if len(box.Files) != 1 || box.Files[0].Name != "note.txt" || box.Files[0].Size != 11 {
|
||||||
|
t.Fatalf("unexpected box files: %+v", box.Files)
|
||||||
|
}
|
||||||
|
object, err := app.uploadService.OpenFileObject(context.Background(), box, box.Files[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFileObject returned error: %v", err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(object.Body)
|
||||||
|
object.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "hello world" {
|
||||||
|
t.Fatalf("uploaded body = %q", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableUploadRequiresAllChunks(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":8,"contentType":"text/plain"}]}`))
|
||||||
|
createResponse := httptest.NewRecorder()
|
||||||
|
app.CreateResumableUpload(createResponse, createRequest)
|
||||||
|
if createResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
|
||||||
|
}
|
||||||
|
var session struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ResumeToken string `json:"resumeToken"`
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal session returned error: %v", err)
|
||||||
|
}
|
||||||
|
chunkRequest := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("hell"))
|
||||||
|
chunkRequest.SetPathValue("sessionID", session.SessionID)
|
||||||
|
chunkRequest.SetPathValue("fileID", session.Files[0].ID)
|
||||||
|
chunkRequest.SetPathValue("index", "0")
|
||||||
|
chunkRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
chunkResponse := httptest.NewRecorder()
|
||||||
|
app.PutResumableChunk(chunkResponse, chunkRequest)
|
||||||
|
if chunkResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("chunk status = %d, body = %s", chunkResponse.Code, chunkResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
|
||||||
|
completeRequest.SetPathValue("sessionID", session.SessionID)
|
||||||
|
completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
completeResponse := httptest.NewRecorder()
|
||||||
|
app.CompleteResumableUpload(completeResponse, completeRequest)
|
||||||
|
if completeResponse.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableStatusRequiresResumeToken(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":4,"contentType":"text/plain"}]}`))
|
||||||
|
createResponse := httptest.NewRecorder()
|
||||||
|
app.CreateResumableUpload(createResponse, createRequest)
|
||||||
|
if createResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
|
||||||
|
}
|
||||||
|
var session struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ResumeToken string `json:"resumeToken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal session returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
|
||||||
|
missing.SetPathValue("sessionID", session.SessionID)
|
||||||
|
missingResponse := httptest.NewRecorder()
|
||||||
|
app.ResumableUploadStatus(missingResponse, missing)
|
||||||
|
if missingResponse.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("missing token status = %d, body = %s", missingResponse.Code, missingResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
wrong := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
|
||||||
|
wrong.SetPathValue("sessionID", session.SessionID)
|
||||||
|
wrong.Header.Set("X-Warpbox-Resume-Token", "wrong")
|
||||||
|
wrongResponse := httptest.NewRecorder()
|
||||||
|
app.ResumableUploadStatus(wrongResponse, wrong)
|
||||||
|
if wrongResponse.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong token status = %d, body = %s", wrongResponse.Code, wrongResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
|
||||||
|
valid.SetPathValue("sessionID", session.SessionID)
|
||||||
|
valid.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
validResponse := httptest.NewRecorder()
|
||||||
|
app.ResumableUploadStatus(validResponse, valid)
|
||||||
|
if validResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("valid token status = %d, body = %s", validResponse.Code, validResponse.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(validResponse.Body.String(), "resumeTokenHash") {
|
||||||
|
t.Fatalf("status response leaked token hash: %s", validResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableUploadCanAddFilesToExistingSession(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createBody := `{"files":[{"name":"one.txt","size":4,"contentType":"text/plain","fingerprint":"one"}],"expiresMinutes":60}`
|
||||||
|
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
|
||||||
|
createResponse := httptest.NewRecorder()
|
||||||
|
app.CreateResumableUpload(createResponse, createRequest)
|
||||||
|
if createResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
|
||||||
|
}
|
||||||
|
var session struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ResumeToken string `json:"resumeToken"`
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal session returned error: %v", err)
|
||||||
|
}
|
||||||
|
firstChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("one!"))
|
||||||
|
firstChunk.SetPathValue("sessionID", session.SessionID)
|
||||||
|
firstChunk.SetPathValue("fileID", session.Files[0].ID)
|
||||||
|
firstChunk.SetPathValue("index", "0")
|
||||||
|
firstChunk.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
firstChunkResponse := httptest.NewRecorder()
|
||||||
|
app.PutResumableChunk(firstChunkResponse, firstChunk)
|
||||||
|
if firstChunkResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first chunk status = %d, body = %s", firstChunkResponse.Code, firstChunkResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
addRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/files", strings.NewReader(`{"files":[{"name":"two.txt","size":4,"contentType":"text/plain","fingerprint":"two"}]}`))
|
||||||
|
addRequest.SetPathValue("sessionID", session.SessionID)
|
||||||
|
addRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
addResponse := httptest.NewRecorder()
|
||||||
|
app.AddResumableFiles(addResponse, addRequest)
|
||||||
|
if addResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("add status = %d, body = %s", addResponse.Code, addResponse.Body.String())
|
||||||
|
}
|
||||||
|
var updated struct {
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UploadedChunks []int `json:"uploadedChunks"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(addResponse.Body.Bytes(), &updated); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal updated returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(updated.Files) != 2 || len(updated.Files[0].UploadedChunks) != 1 {
|
||||||
|
t.Fatalf("unexpected updated session: %+v", updated)
|
||||||
|
}
|
||||||
|
secondChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+updated.Files[1].ID+"/chunks/0", strings.NewReader("two!"))
|
||||||
|
secondChunk.SetPathValue("sessionID", session.SessionID)
|
||||||
|
secondChunk.SetPathValue("fileID", updated.Files[1].ID)
|
||||||
|
secondChunk.SetPathValue("index", "0")
|
||||||
|
secondChunk.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
secondChunkResponse := httptest.NewRecorder()
|
||||||
|
app.PutResumableChunk(secondChunkResponse, secondChunk)
|
||||||
|
if secondChunkResponse.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second chunk status = %d, body = %s", secondChunkResponse.Code, secondChunkResponse.Body.String())
|
||||||
|
}
|
||||||
|
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
|
||||||
|
completeRequest.SetPathValue("sessionID", session.SessionID)
|
||||||
|
completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
completeResponse := httptest.NewRecorder()
|
||||||
|
app.CompleteResumableUpload(completeResponse, completeRequest)
|
||||||
|
if completeResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal result returned error: %v", err)
|
||||||
|
}
|
||||||
|
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(box.Files) != 2 {
|
||||||
|
t.Fatalf("box file count = %d, want 2", len(box.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableCompleteUploadedRequiresTokenAndKeepsFinishedFiles(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
createBody := `{"files":[{"name":"done.txt","size":4,"contentType":"text/plain","fingerprint":"done"},{"name":"partial.txt","size":8,"contentType":"text/plain","fingerprint":"partial"}],"expiresMinutes":60}`
|
||||||
|
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
|
||||||
|
createResponse := httptest.NewRecorder()
|
||||||
|
app.CreateResumableUpload(createResponse, createRequest)
|
||||||
|
if createResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
|
||||||
|
}
|
||||||
|
var session struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
ResumeToken string `json:"resumeToken"`
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"files"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal session returned error: %v", err)
|
||||||
|
}
|
||||||
|
for _, chunk := range []struct {
|
||||||
|
fileIndex int
|
||||||
|
index string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{fileIndex: 0, index: "0", body: "done"},
|
||||||
|
{fileIndex: 1, index: "0", body: "part"},
|
||||||
|
} {
|
||||||
|
request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[chunk.fileIndex].ID+"/chunks/"+chunk.index, strings.NewReader(chunk.body))
|
||||||
|
request.SetPathValue("sessionID", session.SessionID)
|
||||||
|
request.SetPathValue("fileID", session.Files[chunk.fileIndex].ID)
|
||||||
|
request.SetPathValue("index", chunk.index)
|
||||||
|
request.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.PutResumableChunk(response, request)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("chunk status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete-uploaded", nil)
|
||||||
|
missing.SetPathValue("sessionID", session.SessionID)
|
||||||
|
missingResponse := httptest.NewRecorder()
|
||||||
|
app.CompleteUploadedResumableUpload(missingResponse, missing)
|
||||||
|
if missingResponse.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("missing token status = %d, body = %s", missingResponse.Code, missingResponse.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
complete := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete-uploaded", nil)
|
||||||
|
complete.SetPathValue("sessionID", session.SessionID)
|
||||||
|
complete.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
|
||||||
|
completeResponse := httptest.NewRecorder()
|
||||||
|
app.CompleteUploadedResumableUpload(completeResponse, complete)
|
||||||
|
if completeResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("complete-uploaded status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal result returned error: %v", err)
|
||||||
|
}
|
||||||
|
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(box.Files) != 1 || box.Files[0].Name != "done.txt" {
|
||||||
|
t.Fatalf("complete-uploaded box files = %+v", box.Files)
|
||||||
|
}
|
||||||
|
if _, err := app.uploadService.GetResumableSession(session.SessionID); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("GetResumableSession after complete-uploaded error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManageBoxAndDeleteFlow(t *testing.T) {
|
func TestManageBoxAndDeleteFlow(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -179,11 +757,15 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
AppName: "warpbox.dev",
|
AppName: "warpbox.dev",
|
||||||
|
AppVersion: "test",
|
||||||
BaseURL: "http://example.test",
|
BaseURL: "http://example.test",
|
||||||
DataDir: filepath.Join(root, "data"),
|
DataDir: filepath.Join(root, "data"),
|
||||||
StaticDir: staticDir,
|
StaticDir: staticDir,
|
||||||
TemplateDir: templateDir,
|
TemplateDir: templateDir,
|
||||||
MaxUploadSize: 1024 * 1024,
|
MaxUploadSize: 1024 * 1024,
|
||||||
|
ResumableUploadsEnabled: true,
|
||||||
|
ResumableChunkSize: 4,
|
||||||
|
ResumableRetention: time.Hour,
|
||||||
DefaultSettings: config.SettingsDefaults{
|
DefaultSettings: config.SettingsDefaults{
|
||||||
AnonymousUploadsEnabled: true,
|
AnonymousUploadsEnabled: true,
|
||||||
AnonymousMaxUploadMB: 1,
|
AnonymousMaxUploadMB: 1,
|
||||||
@@ -191,13 +773,25 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
UserDailyUploadMB: 8,
|
UserDailyUploadMB: 8,
|
||||||
DefaultUserStorageMB: 16,
|
DefaultUserStorageMB: 16,
|
||||||
UsageRetentionDays: 30,
|
UsageRetentionDays: 30,
|
||||||
|
ResumableUploadsEnabled: true,
|
||||||
|
ResumableChunkSizeMB: 0.000003814697265625,
|
||||||
|
ResumableRetentionHours: 1,
|
||||||
|
ResumableChunkMode: "same",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewUploadService returned error: %v", err)
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
}
|
}
|
||||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
|
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 +806,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)
|
||||||
}
|
}
|
||||||
@@ -220,8 +824,12 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
|
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
|
||||||
|
return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
|
||||||
request.Header.Set("Accept", "application/json")
|
request.Header.Set("Accept", "application/json")
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
app.Upload(response, request)
|
app.Upload(response, request)
|
||||||
@@ -287,6 +895,31 @@ func tokenFromURL(t *testing.T, value string) string {
|
|||||||
return parts[len(parts)-1]
|
return parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitForProcessedBox(t *testing.T, app *App, boxID string) services.Box {
|
||||||
|
t.Helper()
|
||||||
|
var box services.Box
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
next, err := app.uploadService.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
box = next
|
||||||
|
processing := false
|
||||||
|
for _, file := range box.Files {
|
||||||
|
if file.Processing {
|
||||||
|
processing = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !processing {
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("box %s was still processing: %+v", boxID, box.Files)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
func copyDir(src, dst string) error {
|
func copyDir(src, dst string) error {
|
||||||
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,12 +22,35 @@ 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)
|
||||||
}
|
}
|
||||||
|
cleanedUploads, err := uploadService.CleanupExpiredResumableSessions(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("resumable upload cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4204, "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cleanedUploads > 0 {
|
||||||
|
logger.Info("resumable uploads cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2204, "cleaned", cleanedUploads)
|
||||||
|
}
|
||||||
|
if banService != nil {
|
||||||
|
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("ban evidence cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4203, "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cleanedEvents > 0 {
|
||||||
|
logger.Info("ban evidence cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2203, "cleaned", cleanedEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunCleanupNow(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
func RunCleanupNow(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||||
return cleanupUnavailableBoxes(uploadService, logger)
|
cleaned, err := cleanupUnavailableBoxes(uploadService, logger)
|
||||||
|
if err != nil {
|
||||||
|
return cleaned, err
|
||||||
|
}
|
||||||
|
cleanedUploads, err := uploadService.CleanupExpiredResumableSessions(time.Now().UTC())
|
||||||
|
return cleaned + cleanedUploads, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
func cleanupUnavailableBoxes(uploadService *services.UploadService, logger *slog.Logger) (int, error) {
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ type job struct {
|
|||||||
run func()
|
run func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) func() {
|
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) func() {
|
||||||
if !cfg.JobsEnabled {
|
if !cfg.JobsEnabled {
|
||||||
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
|
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
|
||||||
return func() {}
|
return func() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
stops := []func(){
|
stops := []func(){
|
||||||
start(newCleanupJob(cfg, logger, uploadService), logger),
|
start(newCleanupJob(cfg, logger, uploadService, banService), logger),
|
||||||
start(newThumbnailsJob(cfg, logger, uploadService), logger),
|
start(newThumbnailsJob(cfg, logger, uploadService), logger),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -33,6 +45,10 @@ func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger
|
|||||||
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
|
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
logger.Warn("thumbnail one-shot skipped trouble box", "source", "thumbnail", "severity", "warn", "code", 4206, "box_id", boxID, "error", services.BoxTroubleReason(box))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,6 +95,9 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
|
|||||||
if !box.ExpiresAt.After(now) {
|
if !box.ExpiresAt.After(now) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
||||||
result.Scanned += boxResult.Scanned
|
result.Scanned += boxResult.Scanned
|
||||||
@@ -97,30 +116,67 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
if !box.ExpiresAt.After(time.Now().UTC()) {
|
if !box.ExpiresAt.After(time.Now().UTC()) {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
changed := false
|
changed := false
|
||||||
for i := range box.Files {
|
for i := range box.Files {
|
||||||
file := &box.Files[i]
|
file := &box.Files[i]
|
||||||
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
if file.Processing || services.FileHasTrouble(*file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
||||||
|
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
||||||
|
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
||||||
|
if !needsPrimary && !needsScenes && !needsArchive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Scanned++
|
result.Scanned++
|
||||||
|
|
||||||
|
if needsPrimary {
|
||||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||||
result.Failed++
|
result.Failed++
|
||||||
continue
|
} else if thumbnail == "" {
|
||||||
}
|
|
||||||
if thumbnail == "" {
|
|
||||||
result.Failed++
|
result.Failed++
|
||||||
continue
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
file.Thumbnail = thumbnail
|
file.Thumbnail = thumbnail
|
||||||
changed = true
|
changed = true
|
||||||
result.Generated++
|
result.Generated++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsScenes {
|
||||||
|
sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error())
|
||||||
|
result.Failed++
|
||||||
|
} else if sceneThumbnail == "" {
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
file.SceneThumbnail = sceneThumbnail
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsArchive {
|
||||||
|
archiveListing, err := generateArchiveListing(uploadService, box, *file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
|
||||||
|
result.Failed++
|
||||||
|
} else if archiveListing == "" {
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
file.ArchiveListing = archiveListing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
if err := uploadService.SaveBox(box); err != nil {
|
if err := uploadService.SaveBox(box); err != nil {
|
||||||
@@ -131,10 +187,47 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func needsThumbnail(file services.File) bool {
|
func needsThumbnail(file services.File) bool {
|
||||||
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsVideoScenes(file services.File) bool {
|
||||||
|
return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsThumbnail(file services.File) bool {
|
||||||
|
return needsThumbnail(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsVideoScenes(file services.File) bool {
|
||||||
|
return needsVideoScenes(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsArchiveListing(file services.File) bool {
|
||||||
|
return needsArchiveListing(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateThumbnail(uploadService, box, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateVideoScenesThumbnail(uploadService, box, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateArchiveListing(uploadService, box, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||||
|
}
|
||||||
|
if file.Processing {
|
||||||
|
return "", fmt.Errorf("file is still processing")
|
||||||
|
}
|
||||||
|
if services.FileHasTrouble(file) {
|
||||||
|
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||||
|
}
|
||||||
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
||||||
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,11 +250,308 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
|||||||
}
|
}
|
||||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
return thumbnailName, err
|
return thumbnailName, err
|
||||||
|
case isTextThumbnailCandidate(file):
|
||||||
|
data, err := createTextThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return thumbnailName, err
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if !needsVideoScenes(file) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||||
|
}
|
||||||
|
if file.Processing {
|
||||||
|
return "", fmt.Errorf("file is still processing")
|
||||||
|
}
|
||||||
|
if services.FileHasTrouble(file) {
|
||||||
|
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||||
|
}
|
||||||
|
sceneName := "@scene@" + file.ID + ".jpg"
|
||||||
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
data, err := createVideoScenesThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return sceneName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if !needsArchiveListing(file) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if services.BoxHasTrouble(box) {
|
||||||
|
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||||
|
}
|
||||||
|
if file.Processing {
|
||||||
|
return "", fmt.Errorf("file is still processing")
|
||||||
|
}
|
||||||
|
if services.FileHasTrouble(file) {
|
||||||
|
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||||
|
}
|
||||||
|
listingName := "@archive@" + file.ID + ".json"
|
||||||
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
data, err := createArchiveListing(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
|
||||||
|
return listingName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTextThumbnailCandidate(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsArchiveListing(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveListingCurrent(file services.File) bool {
|
||||||
|
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveTreeNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Dir bool `json:"dir"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Children map[string]*archiveTreeNode `json:"-"`
|
||||||
|
Items []*archiveTreeNode `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveListingData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FileCount int `json:"fileCount"`
|
||||||
|
FolderCount int `json:"folderCount"`
|
||||||
|
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||||
|
Root *archiveTreeNode `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(sourceFile.Name())
|
||||||
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
|
sourceFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := zip.OpenReader(sourceFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
|
||||||
|
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
|
||||||
|
var totalSize uint64
|
||||||
|
var fileCount int
|
||||||
|
var dirCount int
|
||||||
|
for _, entry := range archive.File {
|
||||||
|
name := strings.Trim(entry.Name, "/")
|
||||||
|
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
node := root
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node.Children == nil {
|
||||||
|
node.Children = map[string]*archiveTreeNode{}
|
||||||
|
}
|
||||||
|
child, ok := node.Children[part]
|
||||||
|
if !ok {
|
||||||
|
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
|
||||||
|
node.Children[part] = child
|
||||||
|
if child.Dir {
|
||||||
|
dirCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
}
|
||||||
|
if !entry.FileInfo().IsDir() {
|
||||||
|
node.Dir = false
|
||||||
|
node.Size = entry.UncompressedSize64
|
||||||
|
totalSize += entry.UncompressedSize64
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeArchiveTree(root)
|
||||||
|
data := archiveListingData{
|
||||||
|
Name: file.Name,
|
||||||
|
Type: archiveLabel(file),
|
||||||
|
FileCount: fileCount,
|
||||||
|
FolderCount: dirCount,
|
||||||
|
UncompressedSize: totalSize,
|
||||||
|
Root: root,
|
||||||
|
}
|
||||||
|
return json.MarshalIndent(data, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeArchiveTree(node *archiveTreeNode) {
|
||||||
|
node.Items = sortedArchiveChildren(node)
|
||||||
|
for _, child := range node.Items {
|
||||||
|
if child.Dir {
|
||||||
|
child.Icon = "folder"
|
||||||
|
finalizeArchiveTree(child)
|
||||||
|
} else {
|
||||||
|
child.Icon = archiveFileIconName(child.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
|
||||||
|
children := sortedArchiveChildren(node)
|
||||||
|
for i, child := range children {
|
||||||
|
last := i == len(children)-1
|
||||||
|
branch := "|-- "
|
||||||
|
nextPrefix := prefix + "| "
|
||||||
|
if last {
|
||||||
|
branch = "`-- "
|
||||||
|
nextPrefix = prefix + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteString(prefix)
|
||||||
|
out.WriteString(branch)
|
||||||
|
out.WriteString(archiveNodeLabel(child))
|
||||||
|
out.WriteString("\n")
|
||||||
|
if child.Dir {
|
||||||
|
writeArchiveTree(out, child, nextPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
|
||||||
|
children := make([]*archiveTreeNode, 0, len(node.Children))
|
||||||
|
for _, child := range node.Children {
|
||||||
|
children = append(children, child)
|
||||||
|
}
|
||||||
|
sort.Slice(children, func(i, j int) bool {
|
||||||
|
if children[i].Dir != children[j].Dir {
|
||||||
|
return children[i].Dir
|
||||||
|
}
|
||||||
|
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
|
||||||
|
})
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveNodeLabel(node *archiveTreeNode) string {
|
||||||
|
if node.Dir {
|
||||||
|
return "[DIR] " + node.Name + "/"
|
||||||
|
}
|
||||||
|
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFileIcon(name string) string {
|
||||||
|
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFileIconName(name string) string {
|
||||||
|
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
|
||||||
|
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
|
||||||
|
return "img"
|
||||||
|
case "mp4", "mov", "webm", "mkv", "avi":
|
||||||
|
return "vid"
|
||||||
|
case "mp3", "wav", "flac", "ogg", "m4a":
|
||||||
|
return "aud"
|
||||||
|
case "md", "txt", "log", "csv":
|
||||||
|
return "txt"
|
||||||
|
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
|
||||||
|
return "code"
|
||||||
|
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||||
|
return "arc"
|
||||||
|
default:
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveLabel(file services.File) string {
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext != "" {
|
||||||
|
return strings.ToUpper(ext) + " archive"
|
||||||
|
}
|
||||||
|
if file.ContentType != "" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "ZIP-compatible archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatArchiveBytes(size uint64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div := float64(unit)
|
||||||
|
value := float64(size) / div
|
||||||
|
units := []string{"KiB", "MiB", "GiB", "TiB"}
|
||||||
|
for _, suffix := range units {
|
||||||
|
if value < unit {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||||
|
}
|
||||||
|
value /= div
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f PiB", value)
|
||||||
|
}
|
||||||
|
|
||||||
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||||
img, _, err := image.Decode(source)
|
img, _, err := image.Decode(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,17 +580,511 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
|||||||
if err := sourceFile.Close(); err != nil {
|
if err := sourceFile.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
sourcePath := sourceFile.Name()
|
||||||
|
candidates := []string{"00:00:01", "00:00:03", "00:00:06"}
|
||||||
|
var fallback []byte
|
||||||
|
for _, timestamp := range candidates {
|
||||||
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
targetPath := targetFile.Name()
|
targetPath := targetFile.Name()
|
||||||
targetFile.Close()
|
targetFile.Close()
|
||||||
defer os.Remove(targetPath)
|
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
|
||||||
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
|
os.Remove(targetPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
os.Remove(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(fallback) == 0 {
|
||||||
|
fallback = data
|
||||||
|
}
|
||||||
|
if usableVideoFrame(data) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath)
|
||||||
|
if err == nil {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(scenes))
|
||||||
|
if err == nil {
|
||||||
|
thumb := resizeNearest(img, 360, 240)
|
||||||
|
var target bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil {
|
||||||
|
return target.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fallback) > 0 {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("could not extract a usable video thumbnail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return os.ReadFile(targetPath)
|
defer os.Remove(sourceFile.Name())
|
||||||
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
|
sourceFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return createVideoScenesThumbnailFromPath(file, sourceFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) {
|
||||||
|
info := probeVideoInfo(sourcePath, file)
|
||||||
|
timestamps := videoSceneTimestamps(info.Duration)
|
||||||
|
frames := make([]videoSceneFrame, 0, len(timestamps))
|
||||||
|
|
||||||
|
for _, timestamp := range timestamps {
|
||||||
|
targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetPath := targetFile.Name()
|
||||||
|
targetFile.Close()
|
||||||
|
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil {
|
||||||
|
os.Remove(targetPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
os.Remove(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img})
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderVideoScenesThumbnail(file, info, frames), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error {
|
||||||
|
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoSceneFrame struct {
|
||||||
|
Timestamp string
|
||||||
|
Image image.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoInfo struct {
|
||||||
|
Codec string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Duration float64
|
||||||
|
FrameRate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeVideoInfo(sourcePath string, file services.File) videoInfo {
|
||||||
|
info := videoInfo{Codec: "unknown", FrameRate: "unknown"}
|
||||||
|
output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output()
|
||||||
|
if err != nil {
|
||||||
|
if file.ContentType != "" {
|
||||||
|
info.Codec = file.ContentType
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || value == "" || value == "N/A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "codec_name":
|
||||||
|
info.Codec = value
|
||||||
|
case "width":
|
||||||
|
info.Width, _ = strconv.Atoi(value)
|
||||||
|
case "height":
|
||||||
|
info.Height, _ = strconv.Atoi(value)
|
||||||
|
case "duration":
|
||||||
|
info.Duration, _ = strconv.ParseFloat(value, 64)
|
||||||
|
case "avg_frame_rate":
|
||||||
|
info.FrameRate = simplifyFrameRate(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func simplifyFrameRate(value string) string {
|
||||||
|
if value == "0/0" || value == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
n, errN := strconv.ParseFloat(parts[0], 64)
|
||||||
|
d, errD := strconv.ParseFloat(parts[1], 64)
|
||||||
|
if errN != nil || errD != nil || d == 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f fps", n/d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoSceneTimestamps(duration float64) []string {
|
||||||
|
if duration > 4 {
|
||||||
|
points := []float64{0.12, 0.33, 0.58, 0.82}
|
||||||
|
timestamps := make([]string, 0, len(points))
|
||||||
|
for _, point := range points {
|
||||||
|
seconds := duration * point
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
timestamps = append(timestamps, secondsToTimestamp(seconds))
|
||||||
|
}
|
||||||
|
return timestamps
|
||||||
|
}
|
||||||
|
return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsToTimestamp(seconds float64) string {
|
||||||
|
total := int(seconds + 0.5)
|
||||||
|
hours := total / 3600
|
||||||
|
minutes := total % 3600 / 60
|
||||||
|
secs := total % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usableVideoFrame(data []byte) bool {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return averageLuma(img) >= 18
|
||||||
|
}
|
||||||
|
|
||||||
|
func averageLuma(img image.Image) float64 {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
stepX := max(1, width/80)
|
||||||
|
stepY := max(1, height/80)
|
||||||
|
var total float64
|
||||||
|
var samples int
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x += stepX {
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8)
|
||||||
|
samples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if samples == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total / float64(samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff})
|
||||||
|
|
||||||
|
meta := videoMetaLines(file, info)
|
||||||
|
y := 122
|
||||||
|
for _, line := range meta {
|
||||||
|
drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff})
|
||||||
|
y += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
cells := []image.Rectangle{
|
||||||
|
image.Rect(62, 212, 586, 388),
|
||||||
|
image.Rect(614, 212, 1138, 388),
|
||||||
|
image.Rect(62, 414, 586, 566),
|
||||||
|
image.Rect(614, 414, 1138, 566),
|
||||||
|
}
|
||||||
|
for i, rect := range cells {
|
||||||
|
drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff})
|
||||||
|
if i < len(frames) {
|
||||||
|
drawImageCover(canvas, rect, frames[i].Image)
|
||||||
|
drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc})
|
||||||
|
drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoMetaLines(file services.File, info videoInfo) []string {
|
||||||
|
resolution := "unknown resolution"
|
||||||
|
if info.Width > 0 && info.Height > 0 {
|
||||||
|
resolution = fmt.Sprintf("%dx%d", info.Width, info.Height)
|
||||||
|
}
|
||||||
|
duration := "unknown duration"
|
||||||
|
if info.Duration > 0 {
|
||||||
|
duration = secondsToHumanDuration(info.Duration)
|
||||||
|
}
|
||||||
|
contentType := file.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "video"
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
"Duration: " + duration + " Codec: " + info.Codec,
|
||||||
|
"Resolution: " + resolution + " Frame rate: " + info.FrameRate,
|
||||||
|
"Type: " + contentType + " Generated by Warpbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsToHumanDuration(seconds float64) string {
|
||||||
|
total := int(seconds + 0.5)
|
||||||
|
hours := total / 3600
|
||||||
|
minutes := total % 3600 / 60
|
||||||
|
secs := total % 60
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
srcW := bounds.Dx()
|
||||||
|
srcH := bounds.Dy()
|
||||||
|
dstW := rect.Dx()
|
||||||
|
dstH := rect.Dy()
|
||||||
|
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srcRatio := float64(srcW) / float64(srcH)
|
||||||
|
dstRatio := float64(dstW) / float64(dstH)
|
||||||
|
crop := bounds
|
||||||
|
if srcRatio > dstRatio {
|
||||||
|
newW := int(float64(srcH) * dstRatio)
|
||||||
|
x0 := bounds.Min.X + (srcW-newW)/2
|
||||||
|
crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y)
|
||||||
|
} else if srcRatio < dstRatio {
|
||||||
|
newH := int(float64(srcW) / dstRatio)
|
||||||
|
y0 := bounds.Min.Y + (srcH-newH)/2
|
||||||
|
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
u := float64(x-rect.Min.X) / float64(dstW)
|
||||||
|
v := float64(y-rect.Min.Y) / float64(dstH)
|
||||||
|
srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx())))
|
||||||
|
srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy())))
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||||
|
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
|
||||||
|
|
||||||
|
mode := textThumbnailMode(file)
|
||||||
|
title := strings.ToUpper(mode)
|
||||||
|
var lines []string
|
||||||
|
if mode == "HTML" {
|
||||||
|
lines = renderedHTMLThumbnailLines(sourceText)
|
||||||
|
} else if mode == "MARKDOWN" {
|
||||||
|
lines = renderedMarkdownThumbnailLines(sourceText)
|
||||||
|
} else {
|
||||||
|
title = "CODE"
|
||||||
|
lines = codeThumbnailLines(sourceText)
|
||||||
|
}
|
||||||
|
return renderTextThumbnail(file.Name, title, lines), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textThumbnailMode(file services.File) string {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext == "html" || ext == "htm" || contentType == "text/html" {
|
||||||
|
return "HTML"
|
||||||
|
}
|
||||||
|
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
|
||||||
|
return "MARKDOWN"
|
||||||
|
}
|
||||||
|
return "CODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedHTMLThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
|
||||||
|
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
|
||||||
|
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
|
||||||
|
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
|
||||||
|
text = html.UnescapeString(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedMarkdownThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
|
||||||
|
block = strings.Trim(block, "` \n\t")
|
||||||
|
lines := strings.Split(block, "\n")
|
||||||
|
if len(lines) > 1 {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
return "\n" + strings.Join(lines, "\n") + "\n"
|
||||||
|
})
|
||||||
|
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
||||||
|
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentThumbnailLines(source string) []string {
|
||||||
|
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 9)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range wrapTextThumbnailLine(raw, 43) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
if len(lines) >= 9 {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"Rendered preview is empty."}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func codeThumbnailLines(source string) []string {
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 10)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
|
raw = strings.TrimRight(raw, " ")
|
||||||
|
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) > 48 {
|
||||||
|
raw = raw[:45] + "..."
|
||||||
|
}
|
||||||
|
lines = append(lines, raw)
|
||||||
|
if len(lines) >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"(empty file)"}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextThumbnail(name, mode string, lines []string) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
|
||||||
|
|
||||||
|
codePane := image.Rect(22, 72, 338, 210)
|
||||||
|
if mode == "CODE" {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 91
|
||||||
|
for _, line := range lines {
|
||||||
|
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
|
||||||
|
y += 14
|
||||||
|
if y > 202 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
|
||||||
|
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapTextThumbnailLine(text string, maxChars int) []string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text[:maxChars-3] + "..."}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := ""
|
||||||
|
for _, word := range words {
|
||||||
|
if current == "" {
|
||||||
|
current = word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(current)+1+len(word) <= maxChars {
|
||||||
|
current += " " + word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimThumbnailText(text string, maxChars int) string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if maxChars <= 3 {
|
||||||
|
return text[:maxChars]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text[:maxChars-3]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -46,6 +50,151 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateMissingThumbnailsForTroubleBoxSkipsWork(t *testing.T) {
|
||||||
|
service := newThumbnailTestUploadService(t)
|
||||||
|
result := createThumbnailTestBox(t, service)
|
||||||
|
box, err := service.GetBox(result.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
box.Trouble = true
|
||||||
|
box.TroubleReason = "storage backend failed"
|
||||||
|
if err := service.SaveBox(box); err != nil {
|
||||||
|
t.Fatalf("SaveBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobResult, err := generateMissingThumbnailsForBox(service, slog.New(slog.NewTextHandler(io.Discard, nil)), box)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateMissingThumbnailsForBox returned error: %v", err)
|
||||||
|
}
|
||||||
|
if jobResult != (ThumbnailJobResult{}) {
|
||||||
|
t.Fatalf("job result = %+v, want no work for trouble box", jobResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := service.GetBox(result.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBox after job returned error: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Files[0].Thumbnail != "" {
|
||||||
|
t.Fatalf("thumbnail was generated for trouble box: %+v", updated.Files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
|
||||||
|
data, err := createTextThumbnail(services.File{
|
||||||
|
Name: "notes.md",
|
||||||
|
ContentType: "text/markdown",
|
||||||
|
}, strings.NewReader("# Meeting notes\n\n```go\nfunc main() {}\n```\n\nA rendered Markdown preview."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createTextThumbnail returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||||
|
}
|
||||||
|
if img.Bounds().Dx() != 360 || img.Bounds().Dy() != 240 {
|
||||||
|
t.Fatalf("thumbnail size = %dx%d, want 360x240", img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
|
||||||
|
if !needsThumbnail(services.File{Name: "main.go", ContentType: "text/plain"}) {
|
||||||
|
t.Fatalf("Go source file should get a text thumbnail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsableVideoFrameRejectsBlackFrame(t *testing.T) {
|
||||||
|
var dark bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&dark, solidTestImage(color.RGBA{A: 255}), nil); err != nil {
|
||||||
|
t.Fatalf("jpeg.Encode dark returned error: %v", err)
|
||||||
|
}
|
||||||
|
if usableVideoFrame(dark.Bytes()) {
|
||||||
|
t.Fatalf("black video frame should not be usable")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bright bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&bright, solidTestImage(color.RGBA{R: 180, G: 80, B: 40, A: 255}), nil); err != nil {
|
||||||
|
t.Fatalf("jpeg.Encode bright returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !usableVideoFrame(bright.Bytes()) {
|
||||||
|
t.Fatalf("bright video frame should be usable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) {
|
||||||
|
data := renderVideoScenesThumbnail(
|
||||||
|
services.File{Name: "clip.mp4", ContentType: "video/mp4"},
|
||||||
|
videoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 125, FrameRate: "24.00 fps"},
|
||||||
|
[]videoSceneFrame{
|
||||||
|
{Timestamp: "00:00:10", Image: solidTestImage(color.RGBA{R: 140, G: 40, B: 80, A: 255})},
|
||||||
|
{Timestamp: "00:00:35", Image: solidTestImage(color.RGBA{R: 40, G: 120, B: 150, A: 255})},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||||
|
}
|
||||||
|
if img.Bounds().Dx() != 1200 || img.Bounds().Dy() != 630 {
|
||||||
|
t.Fatalf("scene preview size = %dx%d, want 1200x630", img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateArchiveListingRendersZipTree(t *testing.T) {
|
||||||
|
var archive bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&archive)
|
||||||
|
addZipTestFile(t, writer, "docs/readme.md", "hello")
|
||||||
|
addZipTestFile(t, writer, "src/main.go", "package main\n")
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("zip.Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createArchiveListing returned error: %v", err)
|
||||||
|
}
|
||||||
|
var listing archiveListingData
|
||||||
|
if err := json.Unmarshal(data, &listing); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data))
|
||||||
|
}
|
||||||
|
if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 {
|
||||||
|
t.Fatalf("archive listing metadata = %+v", listing)
|
||||||
|
}
|
||||||
|
if listing.Root == nil || len(listing.Root.Items) != 2 {
|
||||||
|
t.Fatalf("archive listing root = %+v", listing.Root)
|
||||||
|
}
|
||||||
|
if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" {
|
||||||
|
t.Fatalf("first archive folder = %+v", listing.Root.Items[0])
|
||||||
|
}
|
||||||
|
if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" {
|
||||||
|
t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0])
|
||||||
|
}
|
||||||
|
if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" {
|
||||||
|
t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) {
|
||||||
|
t.Helper()
|
||||||
|
file, err := writer.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip.Create returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte(body)); err != nil {
|
||||||
|
t.Fatalf("zip file write returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func solidTestImage(c color.Color) image.Image {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 32, 24))
|
||||||
|
for y := 0; y < img.Bounds().Dy(); y++ {
|
||||||
|
for x := 0; x < img.Bounds().Dx(); x++ {
|
||||||
|
img.Set(x, y, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|||||||
64
backend/libs/middleware/bans.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip, ok := services.ClientIPFromContext(r)
|
||||||
|
if !ok {
|
||||||
|
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||||
|
r = services.WithClientIP(r, ip)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
protectedProxy := services.IsProtectedProxyIP(ip, trustedProxies)
|
||||||
|
|
||||||
|
if bans != nil && !protectedProxy {
|
||||||
|
if matched, ok, err := bans.Match(ip, now); err != nil {
|
||||||
|
logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error())
|
||||||
|
} else if ok {
|
||||||
|
logger.Warn("banned request blocked", "source", "ban", "severity", "warn", "code", 4030, "ip", ip, "ban_id", matched.Ban.ID, "target", matched.Ban.Normalized, "path", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte("forbidden\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := bans.Settings()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("ban settings load failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "error", err.Error())
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !settings.AutoBanEnabled {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil {
|
||||||
|
logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error())
|
||||||
|
} else if pattern != "" {
|
||||||
|
if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, settings.MaliciousPathThreshold, now); err != nil {
|
||||||
|
logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error())
|
||||||
|
} else if result.Enabled {
|
||||||
|
logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count)
|
||||||
|
if result.Triggered {
|
||||||
|
logger.Warn("ip auto-banned for malicious path", "source", "ban", "severity", "warn", "code", 4303, "ip", ip, "ban_id", result.Ban.ID, "path", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte("forbidden\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
168
backend/libs/middleware/bans_test.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBansMiddlewareBlocksActiveBan(t *testing.T) {
|
||||||
|
bans := newMiddlewareBanService(t)
|
||||||
|
if _, err := bans.CreateManualBan("203.0.113.20", "test", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateManualBan returned error: %v", err)
|
||||||
|
}
|
||||||
|
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("next handler should not be called")
|
||||||
|
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
request.RemoteAddr = "127.0.0.1:6070"
|
||||||
|
request.Header.Set("X-Forwarded-For", "203.0.113.20")
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusForbidden || response.Body.String() != "forbidden\n" {
|
||||||
|
t.Fatalf("blocked response = %d %q", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBansMiddlewareAllowsNonBannedIP(t *testing.T) {
|
||||||
|
bans := newMiddlewareBanService(t)
|
||||||
|
called := false
|
||||||
|
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_, _ = io.WriteString(w, "ok")
|
||||||
|
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
request.RemoteAddr = "203.0.113.21:6070"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if !called || response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("allowed response = called %v code %d", called, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBansMiddlewareAutoBansMaliciousPaths(t *testing.T) {
|
||||||
|
bans := newMiddlewareBanService(t)
|
||||||
|
settings, err := bans.Settings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Settings returned error: %v", err)
|
||||||
|
}
|
||||||
|
settings.AutoBanEnabled = true
|
||||||
|
settings.MaliciousPathThreshold = 3
|
||||||
|
if err := bans.UpdateSettings(settings); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||||
|
}
|
||||||
|
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||||
|
request.RemoteAddr = "203.0.113.22:6070"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
if i < 2 && response.Code == http.StatusForbidden {
|
||||||
|
t.Fatalf("request %d blocked before threshold", i+1)
|
||||||
|
}
|
||||||
|
if i == 2 && response.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("request 3 status = %d, want forbidden", response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBansMiddlewareSkipsAutoBanWhenDisabled(t *testing.T) {
|
||||||
|
bans := newMiddlewareBanService(t)
|
||||||
|
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||||
|
request.RemoteAddr = "203.0.113.23:6070"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
if response.Code == http.StatusForbidden {
|
||||||
|
t.Fatalf("request %d was blocked while auto-ban disabled", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok, err := bans.Match("203.0.113.23", time.Now().UTC()); err != nil || ok {
|
||||||
|
t.Fatalf("disabled auto-ban Match = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBansMiddlewareDoesNotBlockProtectedProxyIP(t *testing.T) {
|
||||||
|
bans := newMiddlewareBanService(t)
|
||||||
|
if _, err := bans.CreateManualBan("127.0.0.1", "bad historical ban", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
|
||||||
|
t.Fatalf("CreateManualBan returned error: %v", err)
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
_, _ = io.WriteString(w, "ok")
|
||||||
|
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
request.RemoteAddr = "127.0.0.1:6070"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if !called || response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("protected proxy response = called %v code %d", called, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBansMiddlewareDoesNotAutoBanProtectedProxyIP(t *testing.T) {
|
||||||
|
bans := newMiddlewareBanService(t)
|
||||||
|
settings, err := bans.Settings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Settings returned error: %v", err)
|
||||||
|
}
|
||||||
|
settings.AutoBanEnabled = true
|
||||||
|
settings.MaliciousPathThreshold = 1
|
||||||
|
if err := bans.UpdateSettings(settings); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||||
|
}
|
||||||
|
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||||
|
request.RemoteAddr = "127.0.0.1:6070"
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(response, request)
|
||||||
|
|
||||||
|
if response.Code == http.StatusForbidden {
|
||||||
|
t.Fatalf("protected proxy was auto-banned")
|
||||||
|
}
|
||||||
|
if _, ok, err := bans.Match("127.0.0.1", time.Now().UTC()); err != nil || ok {
|
||||||
|
t.Fatalf("protected proxy Match = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMiddlewareBanService(t *testing.T) *services.BanService {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
upload, err := services.NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := upload.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bans, err := services.NewBanService(upload.DB())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBanService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return bans
|
||||||
|
}
|
||||||
16
backend/libs/middleware/client_ip.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClientIP(trustedProxies []string) Middleware {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||||
|
next.ServeHTTP(w, services.WithClientIP(r, ip))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
header.Set("X-Frame-Options", "DENY")
|
header.Set("X-Frame-Options", "DENY")
|
||||||
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'")
|
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-src 'self' about:; base-uri 'self'; frame-ancestors 'none'")
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
571
backend/libs/services/bans.go
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
bansBucket = []byte("bans")
|
||||||
|
abuseEventsBucket = []byte("abuse_events")
|
||||||
|
banRulesBucket = []byte("ban_rules")
|
||||||
|
banSettingsBucket = []byte("ban_settings")
|
||||||
|
banSettingsKey = []byte("settings")
|
||||||
|
defaultBanRulesSeed = []byte("default_rules_seeded")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BanSourceManual = "manual"
|
||||||
|
BanSourceAuto = "auto"
|
||||||
|
|
||||||
|
AbuseKindMaliciousPath = "malicious_path"
|
||||||
|
AbuseKindAdminLogin = "admin_login_failure"
|
||||||
|
AbuseKindUserLogin = "user_login_failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultMaliciousPathRules = []string{
|
||||||
|
"/wp-admin",
|
||||||
|
"/.env",
|
||||||
|
"/.git/config",
|
||||||
|
"/phpmyadmin",
|
||||||
|
"/wp-login.php",
|
||||||
|
"/xmlrpc.php",
|
||||||
|
"/config.php",
|
||||||
|
"/vendor/phpunit",
|
||||||
|
".env",
|
||||||
|
"backup",
|
||||||
|
"dump.sql",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrBanNotFound = errors.New("ban not found")
|
||||||
|
|
||||||
|
type BanService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type BanSettings struct {
|
||||||
|
AutoBanEnabled bool `json:"autoBanEnabled"`
|
||||||
|
AutoBanDurationHours int `json:"autoBanDurationHours"`
|
||||||
|
MaliciousPathThreshold int `json:"maliciousPathThreshold"`
|
||||||
|
AdminLoginFailureThreshold int `json:"adminLoginFailureThreshold"`
|
||||||
|
UserLoginFailureThreshold int `json:"userLoginFailureThreshold"`
|
||||||
|
AbuseWindowHours int `json:"abuseWindowHours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BanRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
Normalized string `json:"normalized"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
CreatedBy string `json:"createdBy,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
UnbannedAt *time.Time `json:"unbannedAt,omitempty"`
|
||||||
|
LastMatchedAt *time.Time `json:"lastMatchedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BanRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AbuseEvent struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
FirstSeen time.Time `json:"firstSeen"`
|
||||||
|
LastSeen time.Time `json:"lastSeen"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchedBan struct {
|
||||||
|
Ban BanRecord
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AbuseResult struct {
|
||||||
|
Event AbuseEvent
|
||||||
|
Ban BanRecord
|
||||||
|
Triggered bool
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBanService(db *bbolt.DB) (*BanService, error) {
|
||||||
|
service := &BanService{db: db}
|
||||||
|
err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
for _, bucket := range [][]byte{bansBucket, abuseEventsBucket, banRulesBucket, banSettingsBucket} {
|
||||||
|
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tx.Bucket(banSettingsBucket).Get(banSettingsKey) == nil {
|
||||||
|
data, err := json.Marshal(DefaultBanSettings())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Bucket(banSettingsBucket).Put(banSettingsKey, data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules := tx.Bucket(banRulesBucket)
|
||||||
|
if rules.Get(defaultBanRulesSeed) == nil {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, pattern := range defaultMaliciousPathRules {
|
||||||
|
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
|
||||||
|
data, err := json.Marshal(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rules.Put([]byte(rule.ID), data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rules.Put(defaultBanRulesSeed, []byte("1")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return service, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultBanSettings() BanSettings {
|
||||||
|
return BanSettings{
|
||||||
|
AutoBanEnabled: false,
|
||||||
|
AutoBanDurationHours: 24,
|
||||||
|
MaliciousPathThreshold: 3,
|
||||||
|
AdminLoginFailureThreshold: 10,
|
||||||
|
UserLoginFailureThreshold: 30,
|
||||||
|
AbuseWindowHours: 24,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) Settings() (BanSettings, error) {
|
||||||
|
settings := DefaultBanSettings()
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
data := tx.Bucket(banSettingsBucket).Get(banSettingsKey)
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
settings = withBanSettingDefaults(settings)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return BanSettings{}, err
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) UpdateSettings(settings BanSettings) error {
|
||||||
|
settings = withBanSettingDefaults(settings)
|
||||||
|
if settings.AutoBanDurationHours <= 0 || settings.MaliciousPathThreshold <= 0 ||
|
||||||
|
settings.AdminLoginFailureThreshold <= 0 || settings.UserLoginFailureThreshold <= 0 ||
|
||||||
|
settings.AbuseWindowHours <= 0 {
|
||||||
|
return fmt.Errorf("ban settings must be positive")
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(banSettingsBucket).Put(banSettingsKey, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withBanSettingDefaults(settings BanSettings) BanSettings {
|
||||||
|
defaults := DefaultBanSettings()
|
||||||
|
if settings.AutoBanDurationHours <= 0 {
|
||||||
|
settings.AutoBanDurationHours = defaults.AutoBanDurationHours
|
||||||
|
}
|
||||||
|
if settings.MaliciousPathThreshold <= 0 {
|
||||||
|
settings.MaliciousPathThreshold = defaults.MaliciousPathThreshold
|
||||||
|
}
|
||||||
|
if settings.AdminLoginFailureThreshold <= 0 {
|
||||||
|
settings.AdminLoginFailureThreshold = defaults.AdminLoginFailureThreshold
|
||||||
|
}
|
||||||
|
if settings.UserLoginFailureThreshold <= 0 {
|
||||||
|
settings.UserLoginFailureThreshold = defaults.UserLoginFailureThreshold
|
||||||
|
}
|
||||||
|
if settings.AbuseWindowHours <= 0 {
|
||||||
|
settings.AbuseWindowHours = defaults.AbuseWindowHours
|
||||||
|
}
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) CreateManualBan(target, reason, createdBy string, expiresAt time.Time) (BanRecord, error) {
|
||||||
|
return s.createBan(target, reason, BanSourceManual, createdBy, expiresAt, time.Now().UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) createBan(target, reason, source, createdBy string, expiresAt, now time.Time) (BanRecord, error) {
|
||||||
|
normalized, err := NormalizeBanTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return BanRecord{}, err
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if reason == "" {
|
||||||
|
return BanRecord{}, fmt.Errorf("ban reason is required")
|
||||||
|
}
|
||||||
|
if !expiresAt.After(now) {
|
||||||
|
return BanRecord{}, fmt.Errorf("ban expiration must be in the future")
|
||||||
|
}
|
||||||
|
record := BanRecord{
|
||||||
|
ID: randomID(12),
|
||||||
|
Target: strings.TrimSpace(target),
|
||||||
|
Normalized: normalized,
|
||||||
|
Reason: reason,
|
||||||
|
Source: source,
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ExpiresAt: expiresAt.UTC(),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return BanRecord{}, err
|
||||||
|
}
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(bansBucket).Put([]byte(record.ID), data)
|
||||||
|
})
|
||||||
|
return record, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeBanTarget(target string) (string, error) {
|
||||||
|
target = strings.TrimSpace(target)
|
||||||
|
if target == "" {
|
||||||
|
return "", fmt.Errorf("ban target is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(target, "/") {
|
||||||
|
_, network, err := net.ParseCIDR(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid CIDR target")
|
||||||
|
}
|
||||||
|
return network.String(), nil
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(target)
|
||||||
|
if ip == nil {
|
||||||
|
return "", fmt.Errorf("invalid IP target")
|
||||||
|
}
|
||||||
|
return ip.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) ListBans() ([]BanRecord, error) {
|
||||||
|
records := []BanRecord{}
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(bansBucket).ForEach(func(_, value []byte) error {
|
||||||
|
var record BanRecord
|
||||||
|
if err := json.Unmarshal(value, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(records, func(i, j int) bool {
|
||||||
|
return records[i].CreatedAt.After(records[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return records, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) Unban(id string, now time.Time) error {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(bansBucket)
|
||||||
|
data := bucket.Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return ErrBanNotFound
|
||||||
|
}
|
||||||
|
var record BanRecord
|
||||||
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now = now.UTC()
|
||||||
|
record.UnbannedAt = &now
|
||||||
|
record.UpdatedAt = now
|
||||||
|
next, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bucket.Put([]byte(id), next)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
|
||||||
|
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||||
|
if parsed == nil {
|
||||||
|
return MatchedBan{}, false, nil
|
||||||
|
}
|
||||||
|
now = now.UTC()
|
||||||
|
var matched BanRecord
|
||||||
|
var matchedKey []byte
|
||||||
|
// Read-only scan first: the common case (no match) only takes a concurrent
|
||||||
|
// read transaction, instead of grabbing the single bbolt write lock on every
|
||||||
|
// request that flows through the ban middleware.
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(bansBucket)
|
||||||
|
return bucket.ForEach(func(key, value []byte) error {
|
||||||
|
if matched.ID != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var record BanRecord
|
||||||
|
if err := json.Unmarshal(value, &record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matched = record
|
||||||
|
matchedKey = append([]byte(nil), key...) // key bytes are only valid within the txn
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil || matched.ID == "" {
|
||||||
|
return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a hit, record the match time in a short write transaction.
|
||||||
|
matched.LastMatchedAt = &now
|
||||||
|
matched.UpdatedAt = now
|
||||||
|
_ = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(bansBucket)
|
||||||
|
data := bucket.Get(matchedKey)
|
||||||
|
if data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var record BanRecord
|
||||||
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
record.LastMatchedAt = &now
|
||||||
|
record.UpdatedAt = now
|
||||||
|
next, err := json.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.Put(matchedKey, next)
|
||||||
|
})
|
||||||
|
return MatchedBan{Ban: matched, IP: ip}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r BanRecord) Active(now time.Time) bool {
|
||||||
|
return r.UnbannedAt == nil && r.ExpiresAt.After(now.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r BanRecord) Status(now time.Time) string {
|
||||||
|
switch {
|
||||||
|
case r.UnbannedAt != nil:
|
||||||
|
return "unbanned"
|
||||||
|
case !r.ExpiresAt.After(now.UTC()):
|
||||||
|
return "expired"
|
||||||
|
default:
|
||||||
|
return "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func banTargetMatches(target string, ip net.IP) bool {
|
||||||
|
if strings.Contains(target, "/") {
|
||||||
|
if _, network, err := net.ParseCIDR(target); err == nil {
|
||||||
|
return network.Contains(ip)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetIP := net.ParseIP(target)
|
||||||
|
return targetIP != nil && targetIP.Equal(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) ListRules() ([]BanRule, error) {
|
||||||
|
rules := []BanRule{}
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(banRulesBucket).ForEach(func(key, value []byte) error {
|
||||||
|
if string(key) == string(defaultBanRulesSeed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rule BanRule
|
||||||
|
if err := json.Unmarshal(value, &rule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules = append(rules, rule)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
sort.Slice(rules, func(i, j int) bool {
|
||||||
|
return strings.ToLower(rules[i].Pattern) < strings.ToLower(rules[j].Pattern)
|
||||||
|
})
|
||||||
|
return rules, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) SaveRules(patterns []string, now time.Time) error {
|
||||||
|
now = now.UTC()
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(banRulesBucket)
|
||||||
|
deleteKeys := [][]byte{}
|
||||||
|
if err := bucket.ForEach(func(key, _ []byte) error {
|
||||||
|
if string(key) == string(defaultBanRulesSeed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, key := range deleteKeys {
|
||||||
|
if err := bucket.Delete(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
pattern = strings.TrimSpace(pattern)
|
||||||
|
if pattern == "" || seen[strings.ToLower(pattern)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[strings.ToLower(pattern)] = true
|
||||||
|
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
|
||||||
|
data, err := json.Marshal(rule)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bucket.Put([]byte(rule.ID), data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) DeleteRule(id string) error {
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
return tx.Bucket(banRulesBucket).Delete([]byte(strings.TrimSpace(id)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) MaliciousPattern(path string) (string, error) {
|
||||||
|
if shouldSkipMaliciousPath(path) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
rules, err := s.ListRules()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
lowerPath := strings.ToLower(path)
|
||||||
|
for _, rule := range rules {
|
||||||
|
if rule.Enabled && strings.Contains(lowerPath, strings.ToLower(rule.Pattern)) {
|
||||||
|
return rule.Pattern, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipMaliciousPath(path string) bool {
|
||||||
|
return path == "/health" || strings.HasPrefix(path, "/static/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {
|
||||||
|
settings, err := s.Settings()
|
||||||
|
if err != nil {
|
||||||
|
return AbuseResult{}, err
|
||||||
|
}
|
||||||
|
if !settings.AutoBanEnabled {
|
||||||
|
return AbuseResult{Enabled: false}, nil
|
||||||
|
}
|
||||||
|
if threshold <= 0 {
|
||||||
|
return AbuseResult{Enabled: true}, nil
|
||||||
|
}
|
||||||
|
now = now.UTC()
|
||||||
|
window := time.Duration(settings.AbuseWindowHours) * time.Hour
|
||||||
|
key := abuseKey(ip, kind)
|
||||||
|
var event AbuseEvent
|
||||||
|
var triggered bool
|
||||||
|
var ban BanRecord
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(abuseEventsBucket)
|
||||||
|
data := bucket.Get([]byte(key))
|
||||||
|
if data != nil {
|
||||||
|
if err := json.Unmarshal(data, &event); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data == nil || now.Sub(event.FirstSeen) > window {
|
||||||
|
event = AbuseEvent{Key: key, IP: ip, Kind: kind, FirstSeen: now}
|
||||||
|
}
|
||||||
|
event.Count++
|
||||||
|
event.LastSeen = now
|
||||||
|
event.Detail = detail
|
||||||
|
next, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := bucket.Put([]byte(key), next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
triggered = event.Count >= threshold
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil || !triggered {
|
||||||
|
return AbuseResult{Event: event, Triggered: false, Enabled: true}, err
|
||||||
|
}
|
||||||
|
if matched, ok, err := s.Match(ip, now); err != nil {
|
||||||
|
return AbuseResult{}, err
|
||||||
|
} else if ok {
|
||||||
|
return AbuseResult{Event: event, Ban: matched.Ban, Triggered: true, Enabled: true}, nil
|
||||||
|
}
|
||||||
|
reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail)
|
||||||
|
ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now)
|
||||||
|
if err != nil {
|
||||||
|
return AbuseResult{}, err
|
||||||
|
}
|
||||||
|
return AbuseResult{Event: event, Ban: ban, Triggered: true, Enabled: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BanService) CleanupAbuseEvents(now time.Time) (int, error) {
|
||||||
|
settings, err := s.Settings()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
cutoff := now.UTC().Add(-time.Duration(settings.AbuseWindowHours) * time.Hour)
|
||||||
|
cleaned := 0
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(abuseEventsBucket)
|
||||||
|
deleteKeys := [][]byte{}
|
||||||
|
if err := bucket.ForEach(func(key, value []byte) error {
|
||||||
|
var event AbuseEvent
|
||||||
|
if err := json.Unmarshal(value, &event); err != nil {
|
||||||
|
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if event.LastSeen.Before(cutoff) {
|
||||||
|
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, key := range deleteKeys {
|
||||||
|
if err := bucket.Delete(key); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return cleaned, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func abuseKey(ip, kind string) string {
|
||||||
|
return kind + ":" + strings.TrimSpace(ip)
|
||||||
|
}
|
||||||
128
backend/libs/services/bans_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBanServiceMatchesIPAndCIDR(t *testing.T) {
|
||||||
|
bans := newTestBanService(t)
|
||||||
|
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
ipBan, err := bans.createBan("203.0.113.5", "single IP", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createBan IP returned error: %v", err)
|
||||||
|
}
|
||||||
|
cidrBan, err := bans.createBan("198.51.100.0/24", "CIDR", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createBan CIDR returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if matched, ok, err := bans.Match("203.0.113.5", now); err != nil || !ok || matched.Ban.ID != ipBan.ID {
|
||||||
|
t.Fatalf("Match IP = %+v, %v, %v", matched, ok, err)
|
||||||
|
}
|
||||||
|
if matched, ok, err := bans.Match("198.51.100.42", now); err != nil || !ok || matched.Ban.ID != cidrBan.ID {
|
||||||
|
t.Fatalf("Match CIDR = %+v, %v, %v", matched, ok, err)
|
||||||
|
}
|
||||||
|
if _, ok, err := bans.Match("192.0.2.1", now); err != nil || ok {
|
||||||
|
t.Fatalf("Match unrelated = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanServiceIgnoresExpiredAndUnbanned(t *testing.T) {
|
||||||
|
bans := newTestBanService(t)
|
||||||
|
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
expired, err := bans.createBan("203.0.113.6", "expired", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createBan expired returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, err := bans.Match("203.0.113.6", now.Add(2*time.Hour)); err != nil || ok {
|
||||||
|
t.Fatalf("expired Match = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
active, err := bans.createBan("203.0.113.7", "active", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createBan active returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := bans.Unban(active.ID, now.Add(time.Minute)); err != nil {
|
||||||
|
t.Fatalf("Unban returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, err := bans.Match("203.0.113.7", now.Add(2*time.Minute)); err != nil || ok {
|
||||||
|
t.Fatalf("unbanned Match = %v, %v", ok, err)
|
||||||
|
}
|
||||||
|
if expired.Status(now.Add(2*time.Hour)) != "expired" {
|
||||||
|
t.Fatalf("expired status = %q", expired.Status(now.Add(2*time.Hour)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanServiceAutoBanThresholdsAndDisabled(t *testing.T) {
|
||||||
|
bans := newTestBanService(t)
|
||||||
|
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
if result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now); err != nil || result.Enabled {
|
||||||
|
t.Fatalf("disabled RecordAbuse = %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
settings, err := bans.Settings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Settings returned error: %v", err)
|
||||||
|
}
|
||||||
|
settings.AutoBanEnabled = true
|
||||||
|
if err := bans.UpdateSettings(settings); err != nil {
|
||||||
|
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(time.Duration(i)*time.Minute))
|
||||||
|
if err != nil || result.Triggered {
|
||||||
|
t.Fatalf("RecordAbuse before threshold = %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(3*time.Minute))
|
||||||
|
if err != nil || !result.Triggered || result.Ban.ID == "" {
|
||||||
|
t.Fatalf("RecordAbuse threshold = %+v, %v", result, err)
|
||||||
|
}
|
||||||
|
again, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(4*time.Minute))
|
||||||
|
if err != nil || !again.Triggered || again.Ban.ID != result.Ban.ID {
|
||||||
|
t.Fatalf("RecordAbuse duplicate = %+v, %v", again, err)
|
||||||
|
}
|
||||||
|
records, err := bans.ListBans()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListBans returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("ban count = %d, want 1", len(records))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBanServiceMaliciousPathRules(t *testing.T) {
|
||||||
|
bans := newTestBanService(t)
|
||||||
|
if pattern, err := bans.MaliciousPattern("/foo/.ENV"); err != nil || pattern == "" {
|
||||||
|
t.Fatalf("MaliciousPattern .env = %q, %v", pattern, err)
|
||||||
|
}
|
||||||
|
if pattern, err := bans.MaliciousPattern("/static/.env"); err != nil || pattern != "" {
|
||||||
|
t.Fatalf("MaliciousPattern static = %q, %v", pattern, err)
|
||||||
|
}
|
||||||
|
if err := bans.SaveRules([]string{"/custom-probe"}, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatalf("SaveRules returned error: %v", err)
|
||||||
|
}
|
||||||
|
if pattern, err := bans.MaliciousPattern("/x/CUSTOM-probe"); err != nil || pattern != "/custom-probe" {
|
||||||
|
t.Fatalf("MaliciousPattern custom = %q, %v", pattern, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBanService(t *testing.T) *BanService {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := upload.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bans, err := NewBanService(upload.DB())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBanService returned error: %v", err)
|
||||||
|
}
|
||||||
|
return bans
|
||||||
|
}
|
||||||
140
backend/libs/services/proxy.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type clientIPContextKey struct{}
|
||||||
|
|
||||||
|
func WithClientIP(r *http.Request, ip string) *http.Request {
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), clientIPContextKey{}, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClientIPFromContext(r *http.Request) (string, bool) {
|
||||||
|
ip, ok := r.Context().Value(clientIPContextKey{}).(string)
|
||||||
|
return ip, ok && ip != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIP resolves the effective client IP. When trustedProxies is empty,
|
||||||
|
// forwarded headers are trusted for easy reverse-proxy/container defaults.
|
||||||
|
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
|
||||||
|
remoteIP := IPOnly(remoteAddr)
|
||||||
|
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {
|
||||||
|
if ip := firstForwardedIP(forwardedFor); ip != "" {
|
||||||
|
return IPOnly(ip)
|
||||||
|
}
|
||||||
|
if ip := strings.TrimSpace(realIP); ip != "" {
|
||||||
|
return IPOnly(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remoteIP
|
||||||
|
}
|
||||||
|
|
||||||
|
func IPOnly(remoteAddr string) string {
|
||||||
|
host := strings.TrimSpace(remoteAddr)
|
||||||
|
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
|
host = splitHost
|
||||||
|
}
|
||||||
|
return strings.Trim(host, "[]")
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsProtectedProxyIP(ip string, trustedProxies []string) bool {
|
||||||
|
parsed := net.ParseIP(IPOnly(ip))
|
||||||
|
if parsed == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if parsed.IsLoopback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return remoteTrusted(parsed.String(), trustedProxies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProtectedBanTarget(target string, trustedProxies []string) bool {
|
||||||
|
normalized, err := NormalizeBanTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.Contains(normalized, "/") {
|
||||||
|
return IsProtectedProxyIP(normalized, trustedProxies)
|
||||||
|
}
|
||||||
|
_, targetNet, err := net.ParseCIDR(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if targetNet.Contains(net.ParseIP("127.0.0.1")) || targetNet.Contains(net.ParseIP("::1")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, trusted := range trustedProxies {
|
||||||
|
trusted = strings.TrimSpace(trusted)
|
||||||
|
if trusted == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(trusted, "/") {
|
||||||
|
if _, trustedNet, err := net.ParseCIDR(trusted); err == nil && networksOverlap(targetNet, trustedNet) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(IPOnly(trusted)); ip != nil && targetNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstForwardedIP(forwardedFor string) string {
|
||||||
|
var fallback string
|
||||||
|
for _, part := range strings.Split(forwardedFor, ",") {
|
||||||
|
ip := IPOnly(part)
|
||||||
|
if net.ParseIP(ip) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fallback == "" {
|
||||||
|
fallback = ip
|
||||||
|
}
|
||||||
|
if isExternalIP(ip) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteTrusted(remoteIP string, trustedProxies []string) bool {
|
||||||
|
parsed := net.ParseIP(remoteIP)
|
||||||
|
if parsed == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, trusted := range trustedProxies {
|
||||||
|
trusted = strings.TrimSpace(trusted)
|
||||||
|
if trusted == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(trusted, "/") {
|
||||||
|
if _, network, err := net.ParseCIDR(trusted); err == nil && network.Contains(parsed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(trusted); ip != nil && ip.Equal(parsed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExternalIP(ip string) bool {
|
||||||
|
parsed := net.ParseIP(IPOnly(ip))
|
||||||
|
return parsed != nil &&
|
||||||
|
!parsed.IsLoopback() &&
|
||||||
|
!parsed.IsPrivate() &&
|
||||||
|
!parsed.IsLinkLocalUnicast() &&
|
||||||
|
!parsed.IsLinkLocalMulticast() &&
|
||||||
|
!parsed.IsUnspecified()
|
||||||
|
}
|
||||||
|
|
||||||
|
func networksOverlap(a, b *net.IPNet) bool {
|
||||||
|
return a.Contains(b.IP) || b.Contains(a.IP)
|
||||||
|
}
|
||||||
74
backend/libs/services/proxy_test.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestClientIPTrustsForwardedHeadersByDefault(t *testing.T) {
|
||||||
|
ip := ClientIP("127.0.0.1:6070", "203.0.113.10, 10.0.0.2", "198.51.100.2", nil)
|
||||||
|
if ip != "203.0.113.10" {
|
||||||
|
t.Fatalf("ClientIP = %q, want forwarded IP", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPUsesTrustedProxyCIDRs(t *testing.T) {
|
||||||
|
trusted := []string{"127.0.0.1", "172.16.0.0/12"}
|
||||||
|
ip := ClientIP("172.20.0.4:6070", "203.0.113.11", "", trusted)
|
||||||
|
if ip != "203.0.113.11" {
|
||||||
|
t.Fatalf("trusted ClientIP = %q", ip)
|
||||||
|
}
|
||||||
|
spoofed := ClientIP("198.51.100.20:6070", "203.0.113.12", "203.0.113.13", trusted)
|
||||||
|
if spoofed != "198.51.100.20" {
|
||||||
|
t.Fatalf("untrusted ClientIP = %q, want remote addr", spoofed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPFallsBackToRealIP(t *testing.T) {
|
||||||
|
ip := ClientIP("127.0.0.1:6070", "", "203.0.113.14", nil)
|
||||||
|
if ip != "203.0.113.14" {
|
||||||
|
t.Fatalf("ClientIP = %q, want real IP", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPStripsPortsFromForwardedHeaders(t *testing.T) {
|
||||||
|
ip := ClientIP("127.0.0.1:6070", "203.0.113.15:49152", "", nil)
|
||||||
|
if ip != "203.0.113.15" {
|
||||||
|
t.Fatalf("ClientIP = %q, want forwarded IP without port", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPPrefersExternalForwardedAddress(t *testing.T) {
|
||||||
|
ip := ClientIP("127.0.0.1:6070", "172.30.0.1, 198.51.100.30", "", nil)
|
||||||
|
if ip != "198.51.100.30" {
|
||||||
|
t.Fatalf("ClientIP = %q, want public forwarded IP", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPOnlyHandlesIPv6HostPort(t *testing.T) {
|
||||||
|
ip := IPOnly("[2001:db8::1]:6070")
|
||||||
|
if ip != "2001:db8::1" {
|
||||||
|
t.Fatalf("IPOnly = %q, want IPv6 address without port", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtectedProxyIP(t *testing.T) {
|
||||||
|
trusted := []string{"127.0.0.1", "172.30.0.1", "10.88.0.0/16"}
|
||||||
|
for _, ip := range []string{"127.0.0.1:48122", "172.30.0.1", "10.88.0.12"} {
|
||||||
|
if !IsProtectedProxyIP(ip, trusted) {
|
||||||
|
t.Fatalf("IsProtectedProxyIP(%q) = false, want true", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if IsProtectedProxyIP("203.0.113.50", trusted) {
|
||||||
|
t.Fatalf("external IP treated as protected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtectedBanTarget(t *testing.T) {
|
||||||
|
trusted := []string{"172.30.0.1", "10.88.0.0/16"}
|
||||||
|
for _, target := range []string{"127.0.0.1", "172.30.0.1", "172.30.0.0/24", "10.88.12.0/24"} {
|
||||||
|
if !ProtectedBanTarget(target, trusted) {
|
||||||
|
t.Fatalf("ProtectedBanTarget(%q) = false, want true", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ProtectedBanTarget("203.0.113.0/24", trusted) {
|
||||||
|
t.Fatalf("external target treated as protected")
|
||||||
|
}
|
||||||
|
}
|
||||||
166
backend/libs/services/reactions.go
Normal 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[:])
|
||||||
|
}
|
||||||
765
backend/libs/services/resumable.go
Normal file
@@ -0,0 +1,765 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var resumableUploadsBucket = []byte("resumable_uploads")
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResumableStatusUploading = "uploading"
|
||||||
|
ResumableStatusProcessing = "processing"
|
||||||
|
ResumableStatusCompleted = "completed"
|
||||||
|
ResumableStatusCancelled = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResumableFileInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResumableSession struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Options UploadOptions `json:"options"`
|
||||||
|
Files []ResumableFile `json:"files"`
|
||||||
|
ChunkSize int64 `json:"chunkSize"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
BoxID string `json:"boxId,omitempty"`
|
||||||
|
ResumeTokenHash string `json:"resumeTokenHash,omitempty"`
|
||||||
|
ResumeToken string `json:"-"`
|
||||||
|
ChunkRoot string `json:"chunkRoot,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResumableFile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
ChunkCount int `json:"chunkCount"`
|
||||||
|
UploadedChunks []int `json:"uploadedChunks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ensureResumableBucket() error {
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists(resumableUploadsBucket)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CreateResumableSession(files []ResumableFileInput, opts UploadOptions, chunkSize int64, retention time.Duration, chunkRoot string) (ResumableSession, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return ResumableSession{}, fmt.Errorf("no files were uploaded")
|
||||||
|
}
|
||||||
|
if chunkSize <= 0 {
|
||||||
|
return ResumableSession{}, fmt.Errorf("chunk size must be positive")
|
||||||
|
}
|
||||||
|
if retention <= 0 {
|
||||||
|
return ResumableSession{}, fmt.Errorf("retention must be positive")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.Password) != "" {
|
||||||
|
opts.PasswordSalt, opts.PasswordHash = hashPassword(opts.Password)
|
||||||
|
opts.Password = ""
|
||||||
|
}
|
||||||
|
sessionFiles, err := s.resumableFilesFromInput(files, opts, chunkSize, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
resumeToken := randomID(32)
|
||||||
|
sessionID := randomID(12)
|
||||||
|
session := ResumableSession{
|
||||||
|
ID: sessionID,
|
||||||
|
Options: opts,
|
||||||
|
Files: sessionFiles,
|
||||||
|
ChunkSize: chunkSize,
|
||||||
|
Status: ResumableStatusUploading,
|
||||||
|
ResumeTokenHash: resumableTokenHash(sessionID, resumeToken),
|
||||||
|
ResumeToken: resumeToken,
|
||||||
|
ChunkRoot: strings.TrimSpace(chunkRoot),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ExpiresAt: now.Add(retention),
|
||||||
|
}
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) VerifyResumableToken(session ResumableSession, token string) bool {
|
||||||
|
if session.ResumeTokenHash == "" || strings.TrimSpace(token) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hash := resumableTokenHash(session.ID, token)
|
||||||
|
return subtle.ConstantTimeCompare([]byte(hash), []byte(session.ResumeTokenHash)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) AddResumableFiles(sessionID string, files []ResumableFileInput) (ResumableSession, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return s.GetResumableSession(sessionID)
|
||||||
|
}
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
existing := make(map[string]bool)
|
||||||
|
for _, file := range session.Files {
|
||||||
|
existing[resumableFileKey(file.Name, file.Size, file.Fingerprint)] = true
|
||||||
|
}
|
||||||
|
newFiles, err := s.resumableFilesFromInput(files, session.Options, session.ChunkSize, existing)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if len(newFiles) == 0 {
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
session.Files = append(session.Files, newFiles...)
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) GetResumableSession(id string) (ResumableSession, error) {
|
||||||
|
var session ResumableSession
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
data := bucket.Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &session)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) PutResumableChunk(ctx context.Context, sessionID, fileID string, index int, body io.Reader) (ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
fileIndex := -1
|
||||||
|
for i, file := range session.Files {
|
||||||
|
if file.ID == fileID {
|
||||||
|
fileIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileIndex < 0 {
|
||||||
|
return ResumableSession{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
file := session.Files[fileIndex]
|
||||||
|
if index < 0 || index >= file.ChunkCount {
|
||||||
|
return ResumableSession{}, fmt.Errorf("chunk index is invalid")
|
||||||
|
}
|
||||||
|
expectedSize := expectedChunkSize(file.Size, session.ChunkSize, index)
|
||||||
|
chunkDir := s.resumableFileDirFor(session, file.ID)
|
||||||
|
if err := os.MkdirAll(chunkDir, 0o755); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
chunkPath := s.resumableChunkPathFor(session, file.ID, index)
|
||||||
|
tempPath := chunkPath + ".tmp"
|
||||||
|
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(target, io.LimitReader(body, expectedSize+1))
|
||||||
|
closeErr := target.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, closeErr
|
||||||
|
}
|
||||||
|
if written != expectedSize {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, fmt.Errorf("chunk size mismatch")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tempPath, chunkPath); err != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Files[fileIndex].UploadedChunks = addChunkIndex(session.Files[fileIndex].UploadedChunks, index)
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CompleteResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if (session.Status == ResumableStatusCompleted || session.Status == ResumableStatusProcessing) && session.BoxID != "" {
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), session, nil
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
staged, err := s.resumableIncomingFiles(session)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.CreateBoxFromIncomingContext(ctx, staged, session.Options)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusCompleted
|
||||||
|
session.BoxID = result.BoxID
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return result, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (UploadResult, ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if (session.Status == ResumableStatusCompleted || session.Status == ResumableStatusProcessing) && session.BoxID != "" {
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), session, nil
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.resumableIncomingFiles(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expiresAt := now.AddDate(0, 0, 7)
|
||||||
|
if session.Options.ExpiresInMinutes < 0 || session.Options.MaxDays < 0 {
|
||||||
|
expiresAt = now.AddDate(100, 0, 0)
|
||||||
|
} else if session.Options.ExpiresInMinutes > 0 {
|
||||||
|
expiresAt = now.Add(time.Duration(session.Options.ExpiresInMinutes) * time.Minute)
|
||||||
|
} else if session.Options.MaxDays > 0 {
|
||||||
|
expiresAt = now.Add(time.Duration(session.Options.MaxDays) * 24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
box := Box{
|
||||||
|
ID: randomID(10),
|
||||||
|
OwnerID: strings.TrimSpace(session.Options.OwnerID),
|
||||||
|
CollectionID: strings.TrimSpace(session.Options.CollectionID),
|
||||||
|
CreatorIP: strings.TrimSpace(session.Options.CreatorIP),
|
||||||
|
StorageBackendID: normalizeBackendID(session.Options.StorageBackendID),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
MaxDownloads: session.Options.MaxDownloads,
|
||||||
|
Obfuscate: session.Options.ObfuscateMetadata && (strings.TrimSpace(session.Options.Password) != "" || strings.TrimSpace(session.Options.PasswordHash) != ""),
|
||||||
|
Files: make([]File, 0, len(session.Files)),
|
||||||
|
}
|
||||||
|
deleteToken := randomID(32)
|
||||||
|
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
||||||
|
if strings.TrimSpace(session.Options.PasswordHash) != "" {
|
||||||
|
box.PasswordSalt = session.Options.PasswordSalt
|
||||||
|
box.PasswordHash = session.Options.PasswordHash
|
||||||
|
} else if strings.TrimSpace(session.Options.Password) != "" {
|
||||||
|
salt, hash := hashPassword(session.Options.Password)
|
||||||
|
box.PasswordSalt = salt
|
||||||
|
box.PasswordHash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, incoming := range session.Files {
|
||||||
|
fileID := randomID(8)
|
||||||
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name))
|
||||||
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
|
contentType := incoming.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
box.Files = append(box.Files, File{
|
||||||
|
ID: fileID,
|
||||||
|
Name: filepath.Base(incoming.Name),
|
||||||
|
StoredName: storedName,
|
||||||
|
Size: incoming.Size,
|
||||||
|
ContentType: contentType,
|
||||||
|
PreviewKind: previewKind(contentType),
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
Processing: true,
|
||||||
|
UploadedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.saveBoxRecord(box); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusProcessing
|
||||||
|
session.BoxID = box.ID
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, deleteToken), session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context, sessionID string) (UploadResult, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if session.Status == ResumableStatusCompleted && session.BoxID != "" {
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), nil
|
||||||
|
}
|
||||||
|
if session.Status != ResumableStatusProcessing || session.BoxID == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("upload session is not processing")
|
||||||
|
}
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
staged, err := s.resumableIncomingFiles(session)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if len(staged) != len(box.Files) {
|
||||||
|
return UploadResult{}, fmt.Errorf("processing file count mismatch")
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.markProcessingBoxFailed(box, err)
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
for i, incoming := range staged {
|
||||||
|
source, err := incoming.Open()
|
||||||
|
if err != nil {
|
||||||
|
_ = s.markProcessingBoxFailed(box, err)
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
file := box.Files[i]
|
||||||
|
if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil {
|
||||||
|
source.Close()
|
||||||
|
_ = backend.Delete(context.Background(), file.ObjectKey)
|
||||||
|
_ = s.markProcessingBoxFailed(box, err)
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
source.Close()
|
||||||
|
box.Files[i].Processing = false
|
||||||
|
box.Files[i].ProcessingError = ""
|
||||||
|
box.Files[i].UploadedAt = time.Now().UTC()
|
||||||
|
if err := s.saveBoxRecord(box); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.writeBoxMetadata(box); err != nil {
|
||||||
|
s.logger.Warn("box metadata write failed after resumable processing", "source", "storage", "severity", "warn", "code", 4020, "box_id", box.ID, "error", err.Error())
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusCompleted
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) markProcessingBoxFailed(box Box, cause error) error {
|
||||||
|
message := "upload processing failed"
|
||||||
|
if cause != nil && strings.TrimSpace(cause.Error()) != "" {
|
||||||
|
message = cause.Error()
|
||||||
|
}
|
||||||
|
s.logger.Warn("resumable upload box marked failed", "source", "user-upload", "severity", "warn", "code", 4021, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "files", len(box.Files), "error", message)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
box.Trouble = true
|
||||||
|
box.TroubleReason = message
|
||||||
|
for i := range box.Files {
|
||||||
|
if box.Files[i].Processing || box.Files[i].ProcessingError == "" {
|
||||||
|
box.Files[i].Processing = false
|
||||||
|
box.Files[i].ProcessingError = message
|
||||||
|
if box.Files[i].UploadedAt.IsZero() {
|
||||||
|
box.Files[i].UploadedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.saveBoxRecord(box); err != nil {
|
||||||
|
s.logger.Warn("failed to save failed upload box state", "source", "user-upload", "severity", "warn", "code", 4022, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.writeBoxMetadata(box); err != nil {
|
||||||
|
s.logger.Warn("failed to write failed upload box metadata", "source", "user-upload", "severity", "warn", "code", 4023, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
completeFiles := make([]ResumableFile, 0, len(session.Files))
|
||||||
|
for _, file := range session.Files {
|
||||||
|
if resumableFileComplete(file) {
|
||||||
|
completeFiles = append(completeFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(completeFiles) == 0 {
|
||||||
|
return UploadResult{}, ResumableSession{}, fmt.Errorf("no fully uploaded files to finish")
|
||||||
|
}
|
||||||
|
partial := session
|
||||||
|
partial.Files = completeFiles
|
||||||
|
staged, err := s.resumableIncomingFiles(partial)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.CreateBoxFromIncomingContext(ctx, staged, session.Options)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusCompleted
|
||||||
|
session.BoxID = result.BoxID
|
||||||
|
session.Files = completeFiles
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.deleteResumableSession(session.ID); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return result, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CancelResumableSession(sessionID string) error {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.deleteResumableSession(session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CleanupExpiredResumableSessions(now time.Time) (int, error) {
|
||||||
|
candidates := make([]ResumableSession, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.ForEach(func(_, value []byte) error {
|
||||||
|
var session ResumableSession
|
||||||
|
if err := json.Unmarshal(value, &session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if session.Status == ResumableStatusCompleted ||
|
||||||
|
session.Status == ResumableStatusCancelled ||
|
||||||
|
(session.Status == ResumableStatusUploading && !session.ExpiresAt.After(now)) {
|
||||||
|
candidates = append(candidates, session)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, session := range candidates {
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, session := range candidates {
|
||||||
|
if err := bucket.Delete([]byte(session.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return len(candidates), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) deleteResumableSession(sessionID string) error {
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.Delete([]byte(sessionID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) saveResumableSession(session ResumableSession) error {
|
||||||
|
if err := s.ensureResumableBucket(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
data, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(resumableUploadsBucket).Put([]byte(session.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
|
||||||
|
sessionFiles := make([]ResumableFile, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
file.Name = filepath.Base(strings.TrimSpace(file.Name))
|
||||||
|
if file.Name == "." || file.Name == "" {
|
||||||
|
return nil, fmt.Errorf("file name is required")
|
||||||
|
}
|
||||||
|
if file.Size < 0 {
|
||||||
|
return nil, fmt.Errorf("file size is invalid")
|
||||||
|
}
|
||||||
|
fingerprint := strings.TrimSpace(file.Fingerprint)
|
||||||
|
key := resumableFileKey(file.Name, file.Size, fingerprint)
|
||||||
|
if existing != nil && existing[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !opts.SkipSizeLimit {
|
||||||
|
if err := s.ValidateSize(file.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks := int((file.Size + chunkSize - 1) / chunkSize)
|
||||||
|
if chunks == 0 {
|
||||||
|
chunks = 1
|
||||||
|
}
|
||||||
|
sessionFiles = append(sessionFiles, ResumableFile{
|
||||||
|
ID: randomID(8),
|
||||||
|
Name: file.Name,
|
||||||
|
Size: file.Size,
|
||||||
|
ContentType: strings.TrimSpace(file.ContentType),
|
||||||
|
Fingerprint: fingerprint,
|
||||||
|
ChunkCount: chunks,
|
||||||
|
})
|
||||||
|
if existing != nil {
|
||||||
|
existing[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessionFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableFileKey(name string, size int64, fingerprint string) string {
|
||||||
|
return strings.TrimSpace(fingerprint) + "|" + filepath.Base(strings.TrimSpace(name)) + "|" + fmt.Sprintf("%d", size)
|
||||||
|
}
|
||||||
|
|
||||||
|
type resumableIncomingFile struct {
|
||||||
|
service *UploadService
|
||||||
|
session ResumableSession
|
||||||
|
file ResumableFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) Name() string {
|
||||||
|
return f.file.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) Size() int64 {
|
||||||
|
return f.file.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) ContentType() string {
|
||||||
|
return f.file.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) Open() (io.ReadCloser, error) {
|
||||||
|
return &resumableChunkReader{
|
||||||
|
service: f.service,
|
||||||
|
session: f.session,
|
||||||
|
file: f.file,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resumableChunkReader struct {
|
||||||
|
service *UploadService
|
||||||
|
session ResumableSession
|
||||||
|
file ResumableFile
|
||||||
|
index int
|
||||||
|
current *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *resumableChunkReader) Read(p []byte) (int, error) {
|
||||||
|
for {
|
||||||
|
if r.current == nil {
|
||||||
|
if r.index >= r.file.ChunkCount {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
chunk, err := os.Open(r.service.resumableChunkPathFor(r.session, r.file.ID, r.index))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
r.current = chunk
|
||||||
|
}
|
||||||
|
n, err := r.current.Read(p)
|
||||||
|
if err == io.EOF {
|
||||||
|
if closeErr := r.current.Close(); closeErr != nil {
|
||||||
|
r.current = nil
|
||||||
|
return n, closeErr
|
||||||
|
}
|
||||||
|
r.current = nil
|
||||||
|
r.index++
|
||||||
|
if n > 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *resumableChunkReader) Close() error {
|
||||||
|
if r.current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := r.current.Close()
|
||||||
|
r.current = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableIncomingFiles(session ResumableSession) ([]IncomingFile, error) {
|
||||||
|
staged := make([]IncomingFile, 0, len(session.Files))
|
||||||
|
for _, file := range session.Files {
|
||||||
|
if len(file.UploadedChunks) != file.ChunkCount {
|
||||||
|
return nil, fmt.Errorf("file %s is missing chunks", file.Name)
|
||||||
|
}
|
||||||
|
var written int64
|
||||||
|
for i := 0; i < file.ChunkCount; i++ {
|
||||||
|
info, err := os.Stat(s.resumableChunkPathFor(session, file.ID, i))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("file %s is missing chunks", file.Name)
|
||||||
|
}
|
||||||
|
written += info.Size()
|
||||||
|
}
|
||||||
|
if written != file.Size {
|
||||||
|
return nil, fmt.Errorf("chunk size total mismatch")
|
||||||
|
}
|
||||||
|
staged = append(staged, resumableIncomingFile{
|
||||||
|
service: s,
|
||||||
|
session: session,
|
||||||
|
file: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return staged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableSessionWritable(session ResumableSession) error {
|
||||||
|
if session.Status != ResumableStatusUploading {
|
||||||
|
return fmt.Errorf("upload session is not active")
|
||||||
|
}
|
||||||
|
if !session.ExpiresAt.After(time.Now().UTC()) {
|
||||||
|
return fmt.Errorf("upload session expired")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableFileComplete(file ResumableFile) bool {
|
||||||
|
return file.ChunkCount > 0 && len(file.UploadedChunks) == file.ChunkCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedChunkSize(fileSize, chunkSize int64, index int) int64 {
|
||||||
|
offset := int64(index) * chunkSize
|
||||||
|
remaining := fileSize - offset
|
||||||
|
if remaining < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if remaining > chunkSize {
|
||||||
|
return chunkSize
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
func addChunkIndex(chunks []int, index int) []int {
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
if chunk == index {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks = append(chunks, index)
|
||||||
|
sort.Ints(chunks)
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableTokenHash(sessionID, token string) string {
|
||||||
|
sum := sha256.Sum256([]byte("warpbox-resumable:" + sessionID + ":" + token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableSessionDir(sessionID string) string {
|
||||||
|
return filepath.Join(s.dataDir, "tmp", "uploads", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableSessionDirFor(session ResumableSession) string {
|
||||||
|
if strings.TrimSpace(session.ChunkRoot) != "" {
|
||||||
|
return filepath.Join(session.ChunkRoot, session.ID)
|
||||||
|
}
|
||||||
|
return s.resumableSessionDir(session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableFileDir(sessionID, fileID string) string {
|
||||||
|
return filepath.Join(s.resumableSessionDir(sessionID), fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableFileDirFor(session ResumableSession, fileID string) string {
|
||||||
|
return filepath.Join(s.resumableSessionDirFor(session), fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableChunkPath(sessionID, fileID string, index int) string {
|
||||||
|
return filepath.Join(s.resumableFileDir(sessionID, fileID), fmt.Sprintf("%06d.part", index))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableChunkPathFor(session ResumableSession, fileID string, index int) string {
|
||||||
|
return filepath.Join(s.resumableFileDirFor(session, fileID), fmt.Sprintf("%06d.part", index))
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -38,6 +37,11 @@ type UploadPolicySettings struct {
|
|||||||
ShortWindowSeconds int `json:"shortWindowSeconds"`
|
ShortWindowSeconds int `json:"shortWindowSeconds"`
|
||||||
AnonymousStorageBackend string `json:"anonymousStorageBackend"`
|
AnonymousStorageBackend string `json:"anonymousStorageBackend"`
|
||||||
UserStorageBackend string `json:"userStorageBackend"`
|
UserStorageBackend string `json:"userStorageBackend"`
|
||||||
|
ResumableUploadsEnabled bool `json:"resumableUploadsEnabled"`
|
||||||
|
ResumableChunkSizeMB float64 `json:"resumableChunkSizeMb"`
|
||||||
|
ResumableRetentionHours int `json:"resumableRetentionHours"`
|
||||||
|
ResumableChunkMode string `json:"resumableChunkMode"`
|
||||||
|
ResumableChunkPath string `json:"resumableChunkPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UsageRecord struct {
|
type UsageRecord struct {
|
||||||
@@ -90,6 +94,11 @@ func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*Settin
|
|||||||
ShortWindowSeconds: defaults.ShortWindowSeconds,
|
ShortWindowSeconds: defaults.ShortWindowSeconds,
|
||||||
AnonymousStorageBackend: defaults.AnonymousStorageBackend,
|
AnonymousStorageBackend: defaults.AnonymousStorageBackend,
|
||||||
UserStorageBackend: defaults.UserStorageBackend,
|
UserStorageBackend: defaults.UserStorageBackend,
|
||||||
|
ResumableUploadsEnabled: defaults.ResumableUploadsEnabled,
|
||||||
|
ResumableChunkSizeMB: defaults.ResumableChunkSizeMB,
|
||||||
|
ResumableRetentionHours: defaults.ResumableRetentionHours,
|
||||||
|
ResumableChunkMode: defaults.ResumableChunkMode,
|
||||||
|
ResumableChunkPath: defaults.ResumableChunkPath,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
service.defaults = service.withBuiltinDefaultGaps(service.defaults)
|
service.defaults = service.withBuiltinDefaultGaps(service.defaults)
|
||||||
@@ -144,6 +153,15 @@ func (s *SettingsService) withBuiltinDefaultGaps(settings UploadPolicySettings)
|
|||||||
if strings.TrimSpace(settings.UserStorageBackend) == "" {
|
if strings.TrimSpace(settings.UserStorageBackend) == "" {
|
||||||
settings.UserStorageBackend = StorageBackendLocal
|
settings.UserStorageBackend = StorageBackendLocal
|
||||||
}
|
}
|
||||||
|
if settings.ResumableChunkSizeMB <= 0 {
|
||||||
|
settings.ResumableChunkSizeMB = 8
|
||||||
|
}
|
||||||
|
if settings.ResumableRetentionHours <= 0 {
|
||||||
|
settings.ResumableRetentionHours = 24
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(settings.ResumableChunkMode) == "" {
|
||||||
|
settings.ResumableChunkMode = "same"
|
||||||
|
}
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +175,13 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
|
|||||||
if err := json.Unmarshal(data, &settings); err != nil {
|
if err := json.Unmarshal(data, &settings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, ok := raw["resumableUploadsEnabled"]; !ok {
|
||||||
|
settings.ResumableUploadsEnabled = s.defaults.ResumableUploadsEnabled
|
||||||
|
}
|
||||||
settings = s.withDefaultGaps(settings)
|
settings = s.withDefaultGaps(settings)
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -218,6 +243,15 @@ func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadP
|
|||||||
if strings.TrimSpace(settings.UserStorageBackend) == "" {
|
if strings.TrimSpace(settings.UserStorageBackend) == "" {
|
||||||
settings.UserStorageBackend = s.defaults.UserStorageBackend
|
settings.UserStorageBackend = s.defaults.UserStorageBackend
|
||||||
}
|
}
|
||||||
|
if settings.ResumableChunkSizeMB <= 0 {
|
||||||
|
settings.ResumableChunkSizeMB = s.defaults.ResumableChunkSizeMB
|
||||||
|
}
|
||||||
|
if settings.ResumableRetentionHours <= 0 {
|
||||||
|
settings.ResumableRetentionHours = s.defaults.ResumableRetentionHours
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(settings.ResumableChunkMode) == "" {
|
||||||
|
settings.ResumableChunkMode = s.defaults.ResumableChunkMode
|
||||||
|
}
|
||||||
return settings
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +268,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
|
||||||
@@ -400,6 +457,18 @@ func (s *SettingsService) validate(settings UploadPolicySettings) error {
|
|||||||
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
|
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
|
||||||
return fmt.Errorf("short-window rate limits must be positive")
|
return fmt.Errorf("short-window rate limits must be positive")
|
||||||
}
|
}
|
||||||
|
if settings.ResumableChunkSizeMB <= 0 {
|
||||||
|
return fmt.Errorf("resumable chunk size must be positive")
|
||||||
|
}
|
||||||
|
if settings.ResumableRetentionHours <= 0 {
|
||||||
|
return fmt.Errorf("resumable retention must be positive")
|
||||||
|
}
|
||||||
|
if settings.ResumableChunkMode != "same" && settings.ResumableChunkMode != "custom" {
|
||||||
|
return fmt.Errorf("resumable chunk storage mode is invalid")
|
||||||
|
}
|
||||||
|
if settings.ResumableChunkMode == "custom" && strings.TrimSpace(settings.ResumableChunkPath) == "" {
|
||||||
|
return fmt.Errorf("custom resumable chunk path is required")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,19 +553,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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -35,26 +35,35 @@ func (b *s3StorageBackend) ID() string { return b.cfg.ID }
|
|||||||
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
|
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
|
||||||
|
|
||||||
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||||
|
cleanKey := cleanObjectKey(key)
|
||||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||||
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
|
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanKey, body, size, opts)
|
||||||
return err
|
if err != nil {
|
||||||
|
return fmt.Errorf("s3 put object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||||
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
|
cleanKey := cleanObjectKey(key)
|
||||||
|
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanKey, minio.GetObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StorageObject{}, err
|
return StorageObject{}, fmt.Errorf("s3 get object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||||
}
|
}
|
||||||
info, err := object.Stat()
|
info, err := object.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
object.Close()
|
object.Close()
|
||||||
return StorageObject{}, err
|
return StorageObject{}, fmt.Errorf("s3 stat object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||||
}
|
}
|
||||||
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
|
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
|
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
|
||||||
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
|
cleanKey := cleanObjectKey(key)
|
||||||
|
if err := b.client.RemoveObject(ctx, b.cfg.Bucket, cleanKey, minio.RemoveObjectOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("s3 delete object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||||
@@ -62,7 +71,7 @@ func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) erro
|
|||||||
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
|
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
|
||||||
for object := range objects {
|
for object := range objects {
|
||||||
if object.Err != nil {
|
if object.Err != nil {
|
||||||
return object.Err
|
return fmt.Errorf("s3 list prefix %q in bucket %q failed: %w", prefix, b.cfg.Bucket, object.Err)
|
||||||
}
|
}
|
||||||
if err := b.Delete(ctx, object.Key); err != nil {
|
if err := b.Delete(ctx, object.Key); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -75,7 +84,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
|||||||
var total int64
|
var total int64
|
||||||
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
||||||
if object.Err != nil {
|
if object.Err != nil {
|
||||||
return 0, object.Err
|
return 0, fmt.Errorf("s3 usage list bucket %q failed: %w", b.cfg.Bucket, object.Err)
|
||||||
}
|
}
|
||||||
total += object.Size
|
total += object.Size
|
||||||
}
|
}
|
||||||
@@ -85,7 +94,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
|||||||
func (b *s3StorageBackend) Test(ctx context.Context) error {
|
func (b *s3StorageBackend) Test(ctx context.Context) error {
|
||||||
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
|
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("s3 bucket check for %q failed: %w", b.cfg.Bucket, err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
|
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ type UploadOptions struct {
|
|||||||
ExpiresInMinutes int
|
ExpiresInMinutes int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
Password string
|
Password string
|
||||||
|
PasswordSalt string
|
||||||
|
PasswordHash string
|
||||||
ObfuscateMetadata bool
|
ObfuscateMetadata bool
|
||||||
OwnerID string
|
OwnerID string
|
||||||
CollectionID string
|
CollectionID string
|
||||||
@@ -50,6 +52,56 @@ type UploadOptions struct {
|
|||||||
StorageBackendID string
|
StorageBackendID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IncomingFile interface {
|
||||||
|
Name() string
|
||||||
|
Size() int64
|
||||||
|
ContentType() string
|
||||||
|
Open() (io.ReadCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type multipartIncomingFile struct {
|
||||||
|
header *multipart.FileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) Name() string {
|
||||||
|
return f.header.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) Size() int64 {
|
||||||
|
return f.header.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) ContentType() string {
|
||||||
|
return f.header.Header.Get("Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) Open() (io.ReadCloser, error) {
|
||||||
|
return f.header.Open()
|
||||||
|
}
|
||||||
|
|
||||||
|
type StagedUploadFile struct {
|
||||||
|
Filename string
|
||||||
|
FileSize int64
|
||||||
|
MIMEType string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) Name() string {
|
||||||
|
return f.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) Size() int64 {
|
||||||
|
return f.FileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) ContentType() string {
|
||||||
|
return f.MIMEType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) Open() (io.ReadCloser, error) {
|
||||||
|
return os.Open(f.Path)
|
||||||
|
}
|
||||||
|
|
||||||
type Box struct {
|
type Box struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OwnerID string `json:"ownerId,omitempty"`
|
OwnerID string `json:"ownerId,omitempty"`
|
||||||
@@ -65,6 +117,8 @@ type Box struct {
|
|||||||
Obfuscate bool `json:"obfuscate"`
|
Obfuscate bool `json:"obfuscate"`
|
||||||
CreatorIP string `json:"creatorIp,omitempty"`
|
CreatorIP string `json:"creatorIp,omitempty"`
|
||||||
StorageBackendID string `json:"storageBackendId,omitempty"`
|
StorageBackendID string `json:"storageBackendId,omitempty"`
|
||||||
|
Trouble bool `json:"trouble,omitempty"`
|
||||||
|
TroubleReason string `json:"troubleReason,omitempty"`
|
||||||
Files []File `json:"files"`
|
Files []File `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,15 +130,53 @@ type File struct {
|
|||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
PreviewKind string `json:"previewKind"`
|
PreviewKind string `json:"previewKind"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
|
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||||
|
ArchiveListing string `json:"archiveListing,omitempty"`
|
||||||
ObjectKey string `json:"objectKey,omitempty"`
|
ObjectKey string `json:"objectKey,omitempty"`
|
||||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||||
|
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
||||||
|
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
|
||||||
|
Processing bool `json:"processing,omitempty"`
|
||||||
|
ProcessingError string `json:"processingError,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploadedAt"`
|
UploadedAt time.Time `json:"uploadedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BoxHasTrouble(box Box) bool {
|
||||||
|
if box.Trouble || strings.TrimSpace(box.TroubleReason) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, file := range box.Files {
|
||||||
|
if FileHasTrouble(file) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func BoxTroubleReason(box Box) string {
|
||||||
|
if strings.TrimSpace(box.TroubleReason) != "" {
|
||||||
|
return box.TroubleReason
|
||||||
|
}
|
||||||
|
for _, file := range box.Files {
|
||||||
|
if strings.TrimSpace(file.ProcessingError) != "" {
|
||||||
|
return file.ProcessingError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if box.Trouble {
|
||||||
|
return "box has failed processing"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func FileHasTrouble(file File) bool {
|
||||||
|
return strings.TrimSpace(file.ProcessingError) != ""
|
||||||
|
}
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
BoxID string `json:"boxId"`
|
BoxID string `json:"boxId"`
|
||||||
BoxURL string `json:"boxUrl"`
|
BoxURL string `json:"boxUrl"`
|
||||||
ZipURL string `json:"zipUrl"`
|
ZipURL string `json:"zipUrl"`
|
||||||
|
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 +188,8 @@ 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"`
|
||||||
|
Processing bool `json:"processing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminStats struct {
|
type AdminStats struct {
|
||||||
@@ -135,6 +229,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 {
|
||||||
@@ -193,17 +290,32 @@ func (s *UploadService) ValidateSize(size int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
||||||
|
return s.CreateBoxFromIncoming(multipartIncomingFiles(files), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CreateBoxFromIncoming(files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
||||||
|
return s.CreateBoxFromIncomingContext(context.Background(), files, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
||||||
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{
|
||||||
@@ -220,65 +332,19 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
}
|
}
|
||||||
deleteToken := randomID(32)
|
deleteToken := randomID(32)
|
||||||
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
||||||
if strings.TrimSpace(opts.Password) != "" {
|
if strings.TrimSpace(opts.PasswordHash) != "" {
|
||||||
|
box.PasswordSalt = opts.PasswordSalt
|
||||||
|
box.PasswordHash = opts.PasswordHash
|
||||||
|
} else if strings.TrimSpace(opts.Password) != "" {
|
||||||
salt, hash := hashPassword(opts.Password)
|
salt, hash := hashPassword(opts.Password)
|
||||||
box.PasswordSalt = salt
|
box.PasswordSalt = salt
|
||||||
box.PasswordHash = hash
|
box.PasswordHash = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
backend, err := s.storage.Backend(box.StorageBackendID)
|
if err := s.writeIncomingFilesToBox(ctx, &box, files, opts); err != nil {
|
||||||
if err != nil {
|
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, header := range files {
|
|
||||||
if !opts.SkipSizeLimit {
|
|
||||||
if err := s.ValidateSize(header.Size); err != nil {
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
maxSize := s.maxUploadSize
|
|
||||||
if opts.SkipSizeLimit {
|
|
||||||
maxSize = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := header.Open()
|
|
||||||
if err != nil {
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fileID := randomID(8)
|
|
||||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
|
||||||
objectKey := boxObjectKey(box.ID, storedName)
|
|
||||||
contentType := header.Header.Get("Content-Type")
|
|
||||||
if contentType == "" {
|
|
||||||
buffer := make([]byte, 512)
|
|
||||||
n, _ := file.Read(buffer)
|
|
||||||
contentType = http.DetectContentType(buffer[:n])
|
|
||||||
if seeker, ok := file.(io.Seeker); ok {
|
|
||||||
_, _ = seeker.Seek(0, io.SeekStart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
|
||||||
file.Close()
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
|
|
||||||
box.Files = append(box.Files, File{
|
|
||||||
ID: fileID,
|
|
||||||
Name: filepath.Base(header.Filename),
|
|
||||||
StoredName: storedName,
|
|
||||||
Size: header.Size,
|
|
||||||
ContentType: contentType,
|
|
||||||
PreviewKind: previewKind(contentType),
|
|
||||||
ObjectKey: objectKey,
|
|
||||||
UploadedAt: time.Now().UTC(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.SaveBox(box); err != nil {
|
if err := s.SaveBox(box); err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
@@ -294,6 +360,110 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
return s.resultForBox(box, deleteToken), nil
|
return s.resultForBox(box, deleteToken), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppendFiles adds files to an existing box (used to group a ShareX multi-file
|
||||||
|
// selection into a single box). The box keeps its original expiry, password and
|
||||||
|
// other settings; only the new files are written.
|
||||||
|
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
||||||
|
return s.AppendIncomingFiles(boxID, multipartIncomingFiles(files), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) AppendIncomingFiles(boxID string, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
||||||
|
}
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if err := s.writeIncomingFilesToBox(context.Background(), &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 {
|
||||||
|
return s.writeIncomingFilesToBox(context.Background(), box, multipartIncomingFiles(files), opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipartIncomingFiles(files []*multipart.FileHeader) []IncomingFile {
|
||||||
|
incoming := make([]IncomingFile, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
incoming = append(incoming, multipartIncomingFile{header: file})
|
||||||
|
}
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, files []IncomingFile, opts UploadOptions) error {
|
||||||
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, incoming := range files {
|
||||||
|
if !opts.SkipSizeLimit {
|
||||||
|
if err := s.ValidateSize(incoming.Size()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := s.maxUploadSize
|
||||||
|
if opts.SkipSizeLimit {
|
||||||
|
maxSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := incoming.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileID := randomID(8)
|
||||||
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
||||||
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
|
contentType := incoming.ContentType()
|
||||||
|
if contentType == "" || contentType == "application/octet-stream" {
|
||||||
|
buffer := make([]byte, 512)
|
||||||
|
n, _ := file.Read(buffer)
|
||||||
|
contentType = http.DetectContentType(buffer[:n])
|
||||||
|
if seeker, ok := file.(io.Seeker); ok {
|
||||||
|
_, _ = seeker.Seek(0, io.SeekStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil {
|
||||||
|
file.Close()
|
||||||
|
_ = backend.Delete(context.Background(), objectKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
box.Files = append(box.Files, File{
|
||||||
|
ID: fileID,
|
||||||
|
Name: filepath.Base(incoming.Name()),
|
||||||
|
StoredName: storedName,
|
||||||
|
Size: incoming.Size(),
|
||||||
|
ContentType: contentType,
|
||||||
|
PreviewKind: previewKind(contentType),
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
UploadedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) GetBox(id string) (Box, error) {
|
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||||
var box Box
|
var box Box
|
||||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
@@ -506,6 +676,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 +717,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 +736,86 @@ 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)
|
||||||
|
}
|
||||||
|
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
|
||||||
|
_ = backend.Delete(context.Background(), key)
|
||||||
|
}
|
||||||
|
if key := s.ArchiveListingObjectKey(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 {
|
||||||
@@ -584,7 +861,30 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
|
|||||||
return boxObjectKey(box.ID, file.Thumbnail)
|
return boxObjectKey(box.ID, file.Thumbnail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
|
||||||
|
if file.SceneThumbnailObjectKey != "" {
|
||||||
|
return file.SceneThumbnailObjectKey
|
||||||
|
}
|
||||||
|
if file.SceneThumbnail == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return boxObjectKey(box.ID, file.SceneThumbnail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string {
|
||||||
|
if file.ArchiveListingObjectKey != "" {
|
||||||
|
return file.ArchiveListingObjectKey
|
||||||
|
}
|
||||||
|
if file.ArchiveListing == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return boxObjectKey(box.ID, file.ArchiveListing)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
if file.Processing {
|
||||||
|
return StorageObject{}, fmt.Errorf("file is still processing")
|
||||||
|
}
|
||||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return StorageObject{}, err
|
return StorageObject{}, err
|
||||||
@@ -604,6 +904,30 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F
|
|||||||
return backend.Get(ctx, key)
|
return backend.Get(ctx, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
key := s.SceneThumbnailObjectKey(box, file)
|
||||||
|
if key == "" {
|
||||||
|
return StorageObject{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return StorageObject{}, err
|
||||||
|
}
|
||||||
|
return backend.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
|
key := s.ArchiveListingObjectKey(box, file)
|
||||||
|
if key == "" {
|
||||||
|
return StorageObject{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
|
if err != nil {
|
||||||
|
return StorageObject{}, err
|
||||||
|
}
|
||||||
|
return backend.Get(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
||||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -706,6 +1030,13 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) SaveBox(box Box) error {
|
func (s *UploadService) SaveBox(box Box) error {
|
||||||
|
if err := s.saveBoxRecord(box); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeBoxMetadata(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) saveBoxRecord(box Box) error {
|
||||||
if box.StorageBackendID == "" {
|
if box.StorageBackendID == "" {
|
||||||
box.StorageBackendID = StorageBackendLocal
|
box.StorageBackendID = StorageBackendLocal
|
||||||
}
|
}
|
||||||
@@ -715,10 +1046,7 @@ func (s *UploadService) SaveBox(box Box) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
if err := tx.Bucket(boxesBucket).Put([]byte(box.ID), data); err != nil {
|
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.writeBoxMetadata(box)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,13 +1058,23 @@ 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),
|
||||||
|
Processing: file.Processing,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
}
|
}
|
||||||
@@ -771,21 +1109,34 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source multipart.File, size, maxSize int64, contentType string) error {
|
func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source io.Reader, size, maxSize int64, contentType string) error {
|
||||||
var reader io.Reader = source
|
var reader io.Reader = source
|
||||||
|
putSize := size
|
||||||
if maxSize > 0 {
|
if maxSize > 0 {
|
||||||
reader = io.LimitReader(source, maxSize+1)
|
if size > maxSize {
|
||||||
var buffer bytes.Buffer
|
|
||||||
written, err := io.Copy(&buffer, reader)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if written > maxSize {
|
|
||||||
return fmt.Errorf("file exceeds max upload size")
|
return fmt.Errorf("file exceeds max upload size")
|
||||||
}
|
}
|
||||||
return backend.Put(ctx, key, bytes.NewReader(buffer.Bytes()), written, contentType)
|
reader = io.LimitReader(source, maxSize)
|
||||||
|
putSize = size
|
||||||
|
}
|
||||||
|
if ctx != nil {
|
||||||
|
reader = contextReader{ctx: ctx, reader: reader}
|
||||||
|
}
|
||||||
|
return backend.Put(ctx, key, reader, putSize, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
type contextReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
reader io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r contextReader) Read(p []byte) (int, error) {
|
||||||
|
select {
|
||||||
|
case <-r.ctx.Done():
|
||||||
|
return 0, r.ctx.Err()
|
||||||
|
default:
|
||||||
|
return r.reader.Read(p)
|
||||||
}
|
}
|
||||||
return backend.Put(ctx, key, reader, size, contentType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boxObjectKey(boxID, name string) string {
|
func boxObjectKey(boxID, name string) string {
|
||||||
@@ -800,6 +1151,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)
|
||||||
|
|||||||
@@ -126,6 +126,303 @@ func TestLocalStorageBackendAndLegacyFallback(t *testing.T) {
|
|||||||
object.Body.Close()
|
object.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResumableSessionUploadOutOfOrderAndComplete(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||||
|
Name: "note.txt",
|
||||||
|
Size: 11,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Fingerprint: "sha256:first-chunk",
|
||||||
|
}}, UploadOptions{MaxDays: 1, Password: "secret"}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if session.ResumeToken == "" || session.ResumeTokenHash == "" {
|
||||||
|
t.Fatalf("resumable session did not create resume token: %+v", session)
|
||||||
|
}
|
||||||
|
if !service.VerifyResumableToken(session, session.ResumeToken) {
|
||||||
|
t.Fatalf("VerifyResumableToken rejected correct token")
|
||||||
|
}
|
||||||
|
if service.VerifyResumableToken(session, "wrong-token") {
|
||||||
|
t.Fatalf("VerifyResumableToken accepted wrong token")
|
||||||
|
}
|
||||||
|
stored, err := service.GetResumableSession(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if stored.ResumeToken != "" {
|
||||||
|
t.Fatalf("stored session leaked raw resume token")
|
||||||
|
}
|
||||||
|
if strings.Contains(stored.ResumeTokenHash, session.ResumeToken) {
|
||||||
|
t.Fatalf("stored token hash contains raw token")
|
||||||
|
}
|
||||||
|
if !service.VerifyResumableToken(stored, session.ResumeToken) {
|
||||||
|
t.Fatalf("stored session rejected correct token")
|
||||||
|
}
|
||||||
|
if session.Options.Password != "" || session.Options.PasswordHash == "" || session.Options.PasswordSalt == "" {
|
||||||
|
t.Fatalf("resumable session did not hash password before storage: %+v", session.Options)
|
||||||
|
}
|
||||||
|
if session.Files[0].ChunkCount != 3 {
|
||||||
|
t.Fatalf("ChunkCount = %d, want 3", session.Files[0].ChunkCount)
|
||||||
|
}
|
||||||
|
if session.Files[0].Fingerprint != "sha256:first-chunk" {
|
||||||
|
t.Fatalf("Fingerprint = %q", session.Files[0].Fingerprint)
|
||||||
|
}
|
||||||
|
for index, body := range map[int]string{2: "rld", 0: "hell", 1: "o wo"} {
|
||||||
|
updated, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, index, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk(%d) returned error: %v", index, err)
|
||||||
|
}
|
||||||
|
if len(updated.Files[0].UploadedChunks) == 0 {
|
||||||
|
t.Fatalf("UploadedChunks was not updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, completed, err := service.CompleteResumableSession(testContext(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if completed.Status != ResumableStatusCompleted || completed.BoxID != result.BoxID {
|
||||||
|
t.Fatalf("completed session = %+v, result = %+v", completed, result)
|
||||||
|
}
|
||||||
|
box := getTestBox(t, service, result.BoxID)
|
||||||
|
if box.PasswordHash == "" || box.PasswordSalt == "" || box.PasswordHash != session.Options.PasswordHash {
|
||||||
|
t.Fatalf("completed box did not preserve hashed password")
|
||||||
|
}
|
||||||
|
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFileObject returned error: %v", err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(object.Body)
|
||||||
|
object.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "hello world" {
|
||||||
|
t.Fatalf("object body = %q", string(data))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(service.resumableSessionDir(session.ID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("resumable temp dir after complete error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
replayed, replayedSession, err := service.CompleteResumableSession(testContext(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteResumableSession replay returned error: %v", err)
|
||||||
|
}
|
||||||
|
if replayed.BoxID != result.BoxID || replayedSession.Status != ResumableStatusCompleted {
|
||||||
|
t.Fatalf("replayed result = %+v, session = %+v, want box %s completed", replayed, replayedSession, result.BoxID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableCompleteRejectsMissingChunks(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||||
|
Name: "note.txt",
|
||||||
|
Size: 8,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("hell")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.CompleteResumableSession(testContext(), session.ID); err == nil {
|
||||||
|
t.Fatalf("CompleteResumableSession accepted missing chunks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessingResumableFailureMarksBoxFailed(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||||
|
Name: "note.txt",
|
||||||
|
Size: 4,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
}}, UploadOptions{MaxDays: 1, StorageBackendID: "missing"}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("note")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk returned error: %v", err)
|
||||||
|
}
|
||||||
|
result, processing, err := service.CreateProcessingBoxFromResumable(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProcessingBoxFromResumable returned error: %v", err)
|
||||||
|
}
|
||||||
|
if processing.Status != ResumableStatusProcessing {
|
||||||
|
t.Fatalf("session status = %q, want processing", processing.Status)
|
||||||
|
}
|
||||||
|
if _, err := service.FinalizeProcessingResumableSession(testContext(), session.ID); err == nil {
|
||||||
|
t.Fatalf("FinalizeProcessingResumableSession accepted missing backend")
|
||||||
|
}
|
||||||
|
box := getTestBox(t, service, result.BoxID)
|
||||||
|
if len(box.Files) != 1 {
|
||||||
|
t.Fatalf("box files = %+v", box.Files)
|
||||||
|
}
|
||||||
|
if box.Files[0].Processing {
|
||||||
|
t.Fatalf("failed file is still marked processing: %+v", box.Files[0])
|
||||||
|
}
|
||||||
|
if box.Files[0].ProcessingError == "" {
|
||||||
|
t.Fatalf("failed file did not store processing error: %+v", box.Files[0])
|
||||||
|
}
|
||||||
|
if !box.Trouble {
|
||||||
|
t.Fatalf("failed box was not marked as trouble: %+v", box)
|
||||||
|
}
|
||||||
|
if box.TroubleReason == "" {
|
||||||
|
t.Fatalf("failed box did not store trouble reason: %+v", box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumablePartialCompleteKeepsOnlyFinishedFiles(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{
|
||||||
|
{Name: "done.txt", Size: 4, ContentType: "text/plain", Fingerprint: "done"},
|
||||||
|
{Name: "partial.txt", Size: 8, ContentType: "text/plain", Fingerprint: "partial"},
|
||||||
|
}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("done")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk done returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[1].ID, 0, strings.NewReader("part")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk partial returned error: %v", err)
|
||||||
|
}
|
||||||
|
result, completed, err := service.CompleteUploadedResumableSession(testContext(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteUploadedResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if completed.Status != ResumableStatusCompleted || completed.BoxID != result.BoxID || len(completed.Files) != 1 {
|
||||||
|
t.Fatalf("completed session = %+v, result = %+v", completed, result)
|
||||||
|
}
|
||||||
|
box := getTestBox(t, service, result.BoxID)
|
||||||
|
if len(box.Files) != 1 || box.Files[0].Name != "done.txt" {
|
||||||
|
t.Fatalf("partial completion box files = %+v", box.Files)
|
||||||
|
}
|
||||||
|
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenFileObject returned error: %v", err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(object.Body)
|
||||||
|
object.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "done" {
|
||||||
|
t.Fatalf("partial completion object = %q", string(data))
|
||||||
|
}
|
||||||
|
if _, err := service.GetResumableSession(session.ID); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("GetResumableSession after partial complete error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(service.resumableSessionDir(session.ID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("resumable temp dir after partial complete error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumablePartialCompleteRejectsNoFinishedFiles(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||||
|
Name: "partial.txt",
|
||||||
|
Size: 8,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("part")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.CompleteUploadedResumableSession(testContext(), session.ID); err == nil {
|
||||||
|
t.Fatalf("CompleteUploadedResumableSession accepted no completed files")
|
||||||
|
}
|
||||||
|
if _, err := service.GetResumableSession(session.ID); err != nil {
|
||||||
|
t.Fatalf("GetResumableSession after failed partial complete returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableSessionCanAddFilesBeforeComplete(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||||
|
Name: "one.txt",
|
||||||
|
Size: 4,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Fingerprint: "one",
|
||||||
|
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("one!")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk one returned error: %v", err)
|
||||||
|
}
|
||||||
|
updated, err := service.AddResumableFiles(session.ID, []ResumableFileInput{{
|
||||||
|
Name: "two.txt",
|
||||||
|
Size: 4,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Fingerprint: "two",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddResumableFiles returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(updated.Files) != 2 {
|
||||||
|
t.Fatalf("files after add = %d, want 2", len(updated.Files))
|
||||||
|
}
|
||||||
|
if updated.Files[0].UploadedChunks[0] != 0 {
|
||||||
|
t.Fatalf("existing uploaded chunk was not preserved: %+v", updated.Files[0])
|
||||||
|
}
|
||||||
|
if _, err := service.AddResumableFiles(session.ID, []ResumableFileInput{{
|
||||||
|
Name: "two.txt",
|
||||||
|
Size: 4,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Fingerprint: "two",
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatalf("duplicate AddResumableFiles returned error: %v", err)
|
||||||
|
}
|
||||||
|
updated, err = service.GetResumableSession(session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(updated.Files) != 2 {
|
||||||
|
t.Fatalf("duplicate add changed file count to %d", len(updated.Files))
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, updated.Files[1].ID, 0, strings.NewReader("two!")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk two returned error: %v", err)
|
||||||
|
}
|
||||||
|
result, _, err := service.CompleteResumableSession(testContext(), session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CompleteResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
box := getTestBox(t, service, result.BoxID)
|
||||||
|
if len(box.Files) != 2 {
|
||||||
|
t.Fatalf("completed box file count = %d, want 2", len(box.Files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumableCleanupRemovesExpiredSessionsAndChunks(t *testing.T) {
|
||||||
|
service := newTestUploadService(t)
|
||||||
|
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||||
|
Name: "note.txt",
|
||||||
|
Size: 4,
|
||||||
|
ContentType: "text/plain",
|
||||||
|
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("hell")); err != nil {
|
||||||
|
t.Fatalf("PutResumableChunk returned error: %v", err)
|
||||||
|
}
|
||||||
|
cleaned, err := service.CleanupExpiredResumableSessions(session.ExpiresAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CleanupExpiredResumableSessions returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cleaned != 1 {
|
||||||
|
t.Fatalf("cleaned = %d, want 1", cleaned)
|
||||||
|
}
|
||||||
|
if _, err := service.GetResumableSession(session.ID); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("GetResumableSession after cleanup error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(service.resumableSessionDir(session.ID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("resumable temp dir after cleanup error = %v, want os.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContaboStorageConfigAllowsDisplayNamesWithSpaces(t *testing.T) {
|
func TestContaboStorageConfigAllowsDisplayNamesWithSpaces(t *testing.T) {
|
||||||
service := newTestUploadService(t)
|
service := newTestUploadService(t)
|
||||||
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
|
||||||
|
|||||||
@@ -7,25 +7,37 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RobotsNone is used for private, protected, expired, or temporary pages.
|
||||||
|
const RobotsNone = "noindex,nofollow,noarchive"
|
||||||
|
|
||||||
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
|
||||||
|
CanonicalURL string
|
||||||
|
Robots string
|
||||||
|
OGType string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
ImageAlt string
|
||||||
|
ImageType string
|
||||||
|
MediaURL string
|
||||||
|
MediaType string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
CurrentUser any
|
CurrentUser any
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
Data any
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
|
func NewRenderer(templateDir, appName, appVersion, baseURL string) (*Renderer, error) {
|
||||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -58,12 +70,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()
|
||||||
|
|
||||||
|
|||||||
BIN
backend/static/WarpBoxLogo.png
Normal file
|
After Width: | Height: | Size: 423 B |
BIN
backend/static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
backend/static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
backend/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -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 {
|
||||||
|
|||||||
263
backend/static/css/04-dialogs.css
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
.warpbox-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 130;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--background) 60%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-overlay.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: min(28rem, 100%);
|
||||||
|
max-height: min(34rem, 90vh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.6rem) scale(0.98);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-overlay.is-visible .warpbox-dialog {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.1rem 3.25rem 0 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-icon {
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-warning .warpbox-dialog-icon {
|
||||||
|
background: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-error .warpbox-dialog-icon {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.1rem;
|
||||||
|
right: 1.1rem;
|
||||||
|
z-index: 2;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
width: 1.9rem;
|
||||||
|
padding: 0;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--surface-1);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-close:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--surface-1-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-body {
|
||||||
|
padding: 0.85rem 1.1rem 1.1rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-message {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-message:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-field {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--input);
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--foreground);
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-field:focus {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0 1.1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.warpbox-dialog-open,
|
||||||
|
html.warpbox-dialog-open body {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: var(--surface-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-icon {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-file-size {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog {
|
||||||
|
border: 1px solid #000000;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 4px 4px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-head {
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog::before {
|
||||||
|
content: "Warpbox";
|
||||||
|
display: block;
|
||||||
|
margin: 0.18rem 0.18rem 0;
|
||||||
|
padding: 0.22rem 0.35rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-error::before {
|
||||||
|
content: "Warpbox - Error";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-warning::before {
|
||||||
|
content: "Warpbox - Warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-info::before {
|
||||||
|
content: "Warpbox - Info";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-icon {
|
||||||
|
border: 1px solid #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000078;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-warning .warpbox-dialog-icon {
|
||||||
|
color: #9a5b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-error .warpbox-dialog-icon {
|
||||||
|
color: #c00000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-message {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-dialog-close {
|
||||||
|
top: 0.36rem;
|
||||||
|
right: 0.3rem;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
min-height: 0.95rem;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.warpbox-dialog-overlay {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-dialog {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-icon {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
88
backend/static/css/17-gruvbox.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Gruvbox theme polish.
|
||||||
|
*
|
||||||
|
* Core colour tokens live in 00-base.css. This file adds the warmer, grounded
|
||||||
|
* Gruvbox-specific surface treatment without changing layout behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .site-header {
|
||||||
|
border-bottom-color: rgba(250, 189, 47, 0.2);
|
||||||
|
backdrop-filter: blur(16px) saturate(130%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(130%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .brand-mark {
|
||||||
|
background: linear-gradient(135deg, #d79921, #fe8019);
|
||||||
|
color: #1d2021;
|
||||||
|
box-shadow: 0 8px 22px rgba(254, 128, 25, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .card,
|
||||||
|
:root[data-theme="gruvbox"] .app-sidebar,
|
||||||
|
:root[data-theme="gruvbox"] .storage-card,
|
||||||
|
:root[data-theme="gruvbox"] .storage-op-card,
|
||||||
|
:root[data-theme="gruvbox"] .metric-card,
|
||||||
|
:root[data-theme="gruvbox"] .logs-filter-card {
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, #1d2021);
|
||||||
|
border-color: rgba(235, 219, 178, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .admin-shell .app-sidebar {
|
||||||
|
border-color: rgba(250, 189, 47, 0.32);
|
||||||
|
background: linear-gradient(180deg, rgba(215, 153, 33, 0.12), rgba(40, 40, 40, 0.94));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .admin-shell .sidebar-link.is-active {
|
||||||
|
border-color: rgba(250, 189, 47, 0.36);
|
||||||
|
background: rgba(215, 153, 33, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .admin-shell .kicker,
|
||||||
|
:root[data-theme="gruvbox"] .kicker {
|
||||||
|
color: #fabd2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] h1 {
|
||||||
|
color: #fbf1c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .button-primary,
|
||||||
|
:root[data-theme="gruvbox"] .button.is-active {
|
||||||
|
border-color: rgba(250, 189, 47, 0.3);
|
||||||
|
background: linear-gradient(135deg, #d79921, #fabd2f);
|
||||||
|
color: #1d2021;
|
||||||
|
box-shadow: 0 10px 24px rgba(215, 153, 33, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .button-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #fabd2f, #fe8019);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .button-outline,
|
||||||
|
:root[data-theme="gruvbox"] .button-ghost:hover,
|
||||||
|
:root[data-theme="gruvbox"] .button-outline:hover {
|
||||||
|
border-color: rgba(235, 219, 178, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .badge-active,
|
||||||
|
:root[data-theme="gruvbox"] .storage-detail-test.is-ok > span:last-child {
|
||||||
|
color: #b8bb26;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] .badge-disabled,
|
||||||
|
:root[data-theme="gruvbox"] .storage-detail-test.is-err > span:last-child,
|
||||||
|
:root[data-theme="gruvbox"] .form-error {
|
||||||
|
color: #fb4934;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] input:focus,
|
||||||
|
:root[data-theme="gruvbox"] select:focus,
|
||||||
|
:root[data-theme="gruvbox"] textarea:focus {
|
||||||
|
border-color: #fe8019;
|
||||||
|
box-shadow: 0 0 0 3px rgba(254, 128, 25, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] ::selection {
|
||||||
|
background: #d79921;
|
||||||
|
color: #1d2021;
|
||||||
|
}
|
||||||
196
backend/static/css/18-cyberpunk.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* CyberPunk theme polish.
|
||||||
|
*
|
||||||
|
* Inspired by neon Cyberpunk 2077 UI treatments: warning yellow surfaces,
|
||||||
|
* cyan/magenta light, hard edges, scanlines, and high-contrast panels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 100% 3px, 3rem 100%;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(115deg, transparent 0 18%, rgba(255, 242, 0, 0.06) 18% 19%, transparent 19% 100%),
|
||||||
|
linear-gradient(245deg, transparent 0 76%, rgba(255, 42, 109, 0.08) 76% 77%, transparent 77% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .site-header {
|
||||||
|
border-bottom-color: rgba(255, 242, 0, 0.32);
|
||||||
|
box-shadow: 0 0 22px rgba(0, 240, 255, 0.12);
|
||||||
|
backdrop-filter: blur(12px) saturate(150%);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .brand {
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .brand-mark {
|
||||||
|
background: #fff200;
|
||||||
|
color: #08070d;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.45), 0 0 18px rgba(255, 242, 0, 0.42);
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 72%, 78% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] h1 {
|
||||||
|
color: #fff200;
|
||||||
|
text-shadow: 2px 0 0 rgba(255, 42, 109, 0.58), -2px 0 0 rgba(0, 240, 255, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .card,
|
||||||
|
:root[data-theme="cyberpunk"] .app-sidebar,
|
||||||
|
:root[data-theme="cyberpunk"] .storage-card,
|
||||||
|
:root[data-theme="cyberpunk"] .storage-op-card,
|
||||||
|
:root[data-theme="cyberpunk"] .metric-card,
|
||||||
|
:root[data-theme="cyberpunk"] .logs-filter-card,
|
||||||
|
:root[data-theme="cyberpunk"] .advanced-options {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(22, 19, 31, 0.96), rgba(13, 10, 20, 0.96)),
|
||||||
|
linear-gradient(90deg, rgba(255, 242, 0, 0.16), rgba(0, 240, 255, 0.08));
|
||||||
|
border-color: rgba(255, 242, 0, 0.28);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .card::before,
|
||||||
|
:root[data-theme="cyberpunk"] .storage-card::before,
|
||||||
|
:root[data-theme="cyberpunk"] .metric-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-top: 1px solid rgba(0, 240, 255, 0.4);
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .admin-shell .app-sidebar {
|
||||||
|
border-color: rgba(255, 42, 109, 0.38);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 42, 109, 0.16), rgba(8, 7, 13, 0.94)),
|
||||||
|
#16131f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .sidebar-link:hover,
|
||||||
|
:root[data-theme="cyberpunk"] .sidebar-link.is-active,
|
||||||
|
:root[data-theme="cyberpunk"] .admin-shell .sidebar-link.is-active {
|
||||||
|
border-color: rgba(255, 242, 0, 0.42);
|
||||||
|
background: linear-gradient(90deg, rgba(255, 242, 0, 0.2), rgba(0, 240, 255, 0.08));
|
||||||
|
color: #fff200;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .kicker,
|
||||||
|
:root[data-theme="cyberpunk"] .admin-shell .kicker {
|
||||||
|
color: #00f0ff;
|
||||||
|
text-shadow: 0 0 12px rgba(0, 240, 255, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .button-primary,
|
||||||
|
:root[data-theme="cyberpunk"] .button.is-active {
|
||||||
|
border-color: #fff200;
|
||||||
|
background: #fff200;
|
||||||
|
color: #08070d;
|
||||||
|
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.7), 0 0 18px rgba(255, 242, 0, 0.3);
|
||||||
|
clip-path: polygon(0 0, calc(100% - 0.7rem) 0, 100% 0.7rem, 100% 100%, 0.7rem 100%, 0 calc(100% - 0.7rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .button-primary:hover,
|
||||||
|
:root[data-theme="cyberpunk"] .button.is-active:hover {
|
||||||
|
background: #00f0ff;
|
||||||
|
border-color: #00f0ff;
|
||||||
|
color: #08070d;
|
||||||
|
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.78), 0 0 22px rgba(0, 240, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .button-outline,
|
||||||
|
:root[data-theme="cyberpunk"] .button-ghost {
|
||||||
|
border-color: rgba(0, 240, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .button-outline:hover,
|
||||||
|
:root[data-theme="cyberpunk"] .button-ghost:hover {
|
||||||
|
border-color: rgba(255, 242, 0, 0.46);
|
||||||
|
background: rgba(255, 242, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] input,
|
||||||
|
:root[data-theme="cyberpunk"] select,
|
||||||
|
:root[data-theme="cyberpunk"] textarea {
|
||||||
|
background: rgba(8, 7, 13, 0.92);
|
||||||
|
border-color: rgba(0, 240, 255, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] input:focus,
|
||||||
|
:root[data-theme="cyberpunk"] select:focus,
|
||||||
|
:root[data-theme="cyberpunk"] textarea:focus {
|
||||||
|
border-color: #fff200;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 242, 0, 0.16), 0 0 22px rgba(0, 240, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .badge {
|
||||||
|
border: 1px solid rgba(0, 240, 255, 0.22);
|
||||||
|
background: rgba(0, 240, 255, 0.08);
|
||||||
|
color: #9bfaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .badge-active,
|
||||||
|
:root[data-theme="cyberpunk"] .storage-detail-test.is-ok > span:last-child {
|
||||||
|
color: #00ff9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .badge-disabled,
|
||||||
|
:root[data-theme="cyberpunk"] .storage-detail-test.is-err > span:last-child,
|
||||||
|
:root[data-theme="cyberpunk"] .form-error {
|
||||||
|
color: #ff2a6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .drop-zone {
|
||||||
|
border-color: rgba(255, 242, 0, 0.34);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(255, 242, 0, 0.08), transparent 38%),
|
||||||
|
rgba(8, 7, 13, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] .drop-zone:hover,
|
||||||
|
:root[data-theme="cyberpunk"] .drop-zone.is-dragging {
|
||||||
|
border-color: #00f0ff;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(0, 240, 255, 0.14), transparent 42%),
|
||||||
|
rgba(8, 7, 13, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] ::selection {
|
||||||
|
background: #ff2a6d;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
:root[data-theme="cyberpunk"] .brand-mark,
|
||||||
|
:root[data-theme="cyberpunk"] h1 {
|
||||||
|
animation: cyberpunk-pulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cyberpunk-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: drop-shadow(0 0 0.45rem rgba(0, 240, 255, 0.28));
|
||||||
|
}
|
||||||
|
}
|
||||||
173
backend/static/css/19-popups.css
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
.warpbox-popups {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 120;
|
||||||
|
inset-block-start: calc(1rem + env(safe-area-inset-top));
|
||||||
|
inset-inline-end: calc(1rem + env(safe-area-inset-right));
|
||||||
|
width: min(26rem, calc(100vw - 2rem));
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup {
|
||||||
|
pointer-events: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.25rem);
|
||||||
|
background: color-mix(in srgb, var(--card) 96%, transparent);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-0.55rem);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-chrome {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.85rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-icon {
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-warning .warpbox-popup-icon {
|
||||||
|
background: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-error .warpbox-popup-icon {
|
||||||
|
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-title {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 0.18rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-close {
|
||||||
|
min-height: 1.8rem;
|
||||||
|
width: 1.8rem;
|
||||||
|
padding: 0;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--surface-1);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-close:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--surface-1-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warpbox-popup-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0 0.95rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popups {
|
||||||
|
inset-block-start: 2.65rem;
|
||||||
|
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup {
|
||||||
|
border: 1px solid #000000;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 3px 3px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup::before {
|
||||||
|
content: "Warpbox";
|
||||||
|
display: block;
|
||||||
|
margin: 0.18rem 0.18rem 0;
|
||||||
|
padding: 0.22rem 0.35rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-error::before {
|
||||||
|
content: "Warpbox - Error";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-warning::before {
|
||||||
|
content: "Warpbox - Warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-info::before {
|
||||||
|
content: "Warpbox - Info";
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-chrome {
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-icon {
|
||||||
|
border: 1px solid #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000078;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-warning .warpbox-popup-icon {
|
||||||
|
color: #9a5b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-error .warpbox-popup-icon {
|
||||||
|
color: #c00000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-message {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .warpbox-popup-close {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.warpbox-popups {
|
||||||
|
inset-inline: 1rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,18 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-options .form-footer .upload-new-button {
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-options .form-footer .upload-new-button[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-pwa-button[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-copy {
|
.hero-copy {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -335,10 +347,13 @@ button {
|
|||||||
.file-progress-side {
|
.file-progress-side {
|
||||||
width: min(10rem, 32vw);
|
width: min(10rem, 32vw);
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-progress-percent {
|
.file-progress-percent {
|
||||||
|
grid-column: 1 / -1;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -349,6 +364,85 @@ button {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-file-remove {
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
min-height: 1.65rem;
|
||||||
|
padding: 0;
|
||||||
|
border-color: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: var(--surface-1);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-remove:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--surface-1-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-waiting {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-complete .file-progress span {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-file-state-shared {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-recovery-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--background) 72%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-recovery-modal {
|
||||||
|
width: min(34rem, 100%);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-recovery-modal h2 {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-recovery-modal p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-recovery-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.upload-recovery-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.result-item small,
|
.result-item small,
|
||||||
.download-item small,
|
.download-item small,
|
||||||
.result-item code,
|
.result-item code,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
299
backend/static/css/80-markdown-preview.css
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--md-bg: #0b0b16;
|
||||||
|
--md-fg: #f5f3ff;
|
||||||
|
--md-muted: #aaa4d6;
|
||||||
|
--md-panel: #17142d;
|
||||||
|
--md-panel-2: #211b3e;
|
||||||
|
--md-border: rgba(168, 150, 255, 0.24);
|
||||||
|
--md-link: #67e8f9;
|
||||||
|
--md-accent: #a78bfa;
|
||||||
|
--md-code-bg: #1b1724;
|
||||||
|
--md-block-code-bg: #0f111a;
|
||||||
|
--md-block-code-fg: #f8fafc;
|
||||||
|
--md-block-code-border: rgba(248, 250, 252, 0.16);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.28);
|
||||||
|
--md-font: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--md-mono: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="classic"] {
|
||||||
|
--md-bg: #09090b;
|
||||||
|
--md-fg: #fafafa;
|
||||||
|
--md-muted: #a1a1aa;
|
||||||
|
--md-panel: #18181b;
|
||||||
|
--md-panel-2: #27272a;
|
||||||
|
--md-border: rgba(255, 255, 255, 0.13);
|
||||||
|
--md-link: #e4e4e7;
|
||||||
|
--md-accent: #d4d4d8;
|
||||||
|
--md-code-bg: #111113;
|
||||||
|
--md-block-code-bg: #09090b;
|
||||||
|
--md-block-code-fg: #fafafa;
|
||||||
|
--md-block-code-border: rgba(250, 250, 250, 0.15);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--md-bg: #c0c0c0;
|
||||||
|
--md-fg: #000000;
|
||||||
|
--md-muted: #404040;
|
||||||
|
--md-panel: #ffffff;
|
||||||
|
--md-panel-2: #dfdfdf;
|
||||||
|
--md-border: #000000;
|
||||||
|
--md-link: #000078;
|
||||||
|
--md-accent: #000078;
|
||||||
|
--md-code-bg: #ffffff;
|
||||||
|
--md-block-code-bg: #000000;
|
||||||
|
--md-block-code-fg: #f5f5f5;
|
||||||
|
--md-block-code-border: #808080;
|
||||||
|
--md-shadow: transparent;
|
||||||
|
--md-font: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
--md-mono: "PixelOperatorMono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--md-bg: #1d2021;
|
||||||
|
--md-fg: #ebdbb2;
|
||||||
|
--md-muted: #bdae93;
|
||||||
|
--md-panel: #282828;
|
||||||
|
--md-panel-2: #32302f;
|
||||||
|
--md-border: rgba(235, 219, 178, 0.2);
|
||||||
|
--md-link: #fabd2f;
|
||||||
|
--md-accent: #d79921;
|
||||||
|
--md-code-bg: #1b1d1e;
|
||||||
|
--md-block-code-bg: #161819;
|
||||||
|
--md-block-code-fg: #fbf1c7;
|
||||||
|
--md-block-code-border: rgba(251, 241, 199, 0.18);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] {
|
||||||
|
--md-bg: #08070d;
|
||||||
|
--md-fg: #fff36f;
|
||||||
|
--md-muted: #9bfaff;
|
||||||
|
--md-panel: #16131f;
|
||||||
|
--md-panel-2: #251d34;
|
||||||
|
--md-border: rgba(255, 242, 0, 0.34);
|
||||||
|
--md-link: #00f0ff;
|
||||||
|
--md-accent: #ff2a6d;
|
||||||
|
--md-code-bg: #100d18;
|
||||||
|
--md-block-code-bg: #07060b;
|
||||||
|
--md-block-code-fg: #f8fafc;
|
||||||
|
--md-block-code-border: rgba(0, 240, 255, 0.26);
|
||||||
|
--md-shadow: rgba(255, 42, 109, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf") format("truetype");
|
||||||
|
font-weight: bold;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixelOperatorMono";
|
||||||
|
src: url("/static/fonts/pixel_operator/PixelOperatorMono.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% -10%, color-mix(in srgb, var(--md-accent) 18%, transparent), transparent 24rem),
|
||||||
|
var(--md-bg);
|
||||||
|
color: var(--md-fg);
|
||||||
|
font-family: var(--md-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] {
|
||||||
|
background-color: #000000;
|
||||||
|
background-image: url("/static/backgrounds/stars1.gif");
|
||||||
|
background-repeat: repeat;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="cyberpunk"] {
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px),
|
||||||
|
var(--md-bg);
|
||||||
|
background-size: 100% 3px, 3rem 100%, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(1rem, 4vw, 2.25rem);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--md-panel) 90%, transparent);
|
||||||
|
box-shadow: 0 20px 60px var(--md-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] main {
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--md-panel);
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #404040,
|
||||||
|
inset 1px 1px 0 #ffffff,
|
||||||
|
inset -2px -2px 0 #808080,
|
||||||
|
inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="cyberpunk"] main {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.5), 0 0 24px rgba(0, 240, 255, 0.12);
|
||||||
|
clip-path: polygon(0 0, calc(100% - 0.9rem) 0, 100% 0.9rem, 100% 100%, 0.9rem 100%, 0 calc(100% - 0.9rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 1.4em 0 0.55em;
|
||||||
|
color: var(--md-fg);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:first-child,
|
||||||
|
h2:first-child,
|
||||||
|
h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.75rem, 5vw, 2.45rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--md-border);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
pre,
|
||||||
|
table {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--md-link);
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--md-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] img,
|
||||||
|
html[data-theme="retro"] video {
|
||||||
|
border-radius: 0;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--md-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid var(--md-accent);
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 58%, transparent);
|
||||||
|
color: var(--md-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--md-block-code-border) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--md-block-code-bg) !important;
|
||||||
|
color: var(--md-block-code-fg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--md-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code,
|
||||||
|
pre > code,
|
||||||
|
pre code[class*="language-"] {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
padding: 0.12rem 0.28rem;
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: color-mix(in srgb, var(--md-code-bg) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] pre,
|
||||||
|
html[data-theme="retro"] :not(pre) > code {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 70%, transparent);
|
||||||
|
color: var(--md-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--md-accent);
|
||||||
|
color: var(--md-bg);
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
BIN
backend/static/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
backend/static/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 483 B |
BIN
backend/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 11 KiB |
112
backend/static/file-icons/icon-map.json
Normal 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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
backend/static/file-icons/retro/mshtml.dll_14_2660-2.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
backend/static/file-icons/retro/shell32.dll_14_151-2.png
Normal file
|
After Width: | Height: | Size: 386 B |
BIN
backend/static/file-icons/retro/shell32.dll_14_152-2.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
backend/static/file-icons/retro/shell32.dll_14_2-0.png
Normal file
|
After Width: | Height: | Size: 553 B |
BIN
backend/static/file-icons/retro/shell32.dll_14_3-0.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
backend/static/file-icons/retro/shimgvw.dll_14_1-2.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
backend/static/file-icons/retro/wmploc.dll_14_504-2.png
Normal file
|
After Width: | Height: | Size: 594 B |
BIN
backend/static/file-icons/retro/wmploc.dll_14_610-2.png
Normal file
|
After Width: | Height: | Size: 621 B |
BIN
backend/static/file-icons/retro/zipfldr.dll_14_101-2.png
Normal file
|
After Width: | Height: | Size: 598 B |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |