Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2cf7115b7 | |||
| a0027fbd18 | |||
| 6a7590493c | |||
| 5d77b36634 | |||
| 0b8d4a3ab9 | |||
| 0b4487ac2e | |||
| ead4cd7492 | |||
| af1fae1a98 | |||
| d11aec96e5 | |||
| dbfdacc396 | |||
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 | |||
| f9755fa98f | |||
| 2eba04b9da | |||
| 81f4ce5e36 | |||
| eff831b142 | |||
| 3b278642dc | |||
| 3a0dd04e61 | |||
| e17c5e92a7 | |||
| f698ba516d | |||
| 17c31be8b4 | |||
| 313c89483c | |||
| 5cd476e7f3 | |||
| d3b6a86753 | |||
| cf5d8bb50d | |||
| 8e3f783780 | |||
| 6c87187c6d |
@@ -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
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -17,3 +17,4 @@ scripts/env/dev.env
|
|||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
.claude
|
.claude
|
||||||
|
docs/possible_new_features
|
||||||
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.
|
||||||
320
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,32 +61,150 @@ Upload policy defaults are also configured in megabytes and can later be changed
|
|||||||
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
|
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
|
||||||
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
|
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
|
||||||
- `WARPBOX_USER_STORAGE_BACKEND=local`
|
- `WARPBOX_USER_STORAGE_BACKEND=local`
|
||||||
- `WARPBOX_TRUSTED_PROXIES=` controls whether forwarded client IP headers are accepted only from specific proxy IPs/CIDRs. See [SECURITY_PROXY.md](./SECURITY_PROXY.md).
|
- `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.
|
||||||
|
|
||||||
Large uploads are expected to take minutes on normal home/server connections. Keep
|
### Background jobs
|
||||||
`WARPBOX_READ_TIMEOUT=0s` and `WARPBOX_WRITE_TIMEOUT=0s` so Go does not close the connection
|
|
||||||
mid-upload; `WARPBOX_READ_HEADER_TIMEOUT=15s` still protects header reads from slowloris-style
|
|
||||||
connections.
|
|
||||||
|
|
||||||
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
|
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
|
||||||
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
|
||||||
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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
|
## Emoji reaction packs
|
||||||
|
|
||||||
File reactions use emoji packs from the runtime data directory, not from the application code. By
|
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
|
default that means `./data/emoji`; if you change `WARPBOX_DATA_DIR`, use `$WARPBOX_DATA_DIR/emoji`
|
||||||
`$WARPBOX_DATA_DIR/emoji` instead.
|
instead.
|
||||||
|
|
||||||
Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files
|
Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files
|
||||||
directly inside the pack folder:
|
directly inside the pack folder:
|
||||||
@@ -82,22 +228,17 @@ data/
|
|||||||
└── shipped.png
|
└── shipped.png
|
||||||
```
|
```
|
||||||
|
|
||||||
In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`.
|
In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`. Supported
|
||||||
Supported emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`.
|
emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`.
|
||||||
|
|
||||||
For one-off Go commands, run them from the backend module:
|
## Deployment
|
||||||
|
|
||||||
```bash
|
### Docker / Podman
|
||||||
cd backend
|
|
||||||
go run ./cmd/warpbox
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
||||||
@@ -105,21 +246,19 @@ 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`.
|
|
||||||
|
|
||||||
## Reverse Proxy Security
|
|
||||||
|
|
||||||
Warpbox uses the resolved client IP for anonymous limits, manual bans, and automatic bans. The
|
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
|
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
|
without extra setup. For hardened deployments where the app port might be reachable from more than one
|
||||||
one network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
|
network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
|
||||||
[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes.
|
[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes.
|
||||||
|
|
||||||
## Systemd
|
### Systemd
|
||||||
|
|
||||||
Build the binary on the server, create a dedicated user, and keep runtime data outside the repo:
|
Build the binary on the server, create a dedicated user, and keep runtime data outside the repo:
|
||||||
|
|
||||||
@@ -181,7 +320,23 @@ sudo systemctl status warpbox
|
|||||||
|
|
||||||
Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the public internet.
|
Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the public internet.
|
||||||
|
|
||||||
## Layout
|
## 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.
|
||||||
@@ -189,7 +344,7 @@ Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the p
|
|||||||
- `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.
|
||||||
@@ -198,92 +353,13 @@ Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the p
|
|||||||
- `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, 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.
|
|
||||||
|
|
||||||
## Stage 4 Accounts + Personal Boxes
|
|
||||||
|
|
||||||
- `/register` bootstraps the first admin account only when no users exist.
|
|
||||||
- `/login` and `/logout` provide cookie-based web sessions.
|
|
||||||
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
|
||||||
history, and flat collections. Uploading still happens from the homepage.
|
|
||||||
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
|
|
||||||
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is
|
|
||||||
stored with owner and optional collection metadata.
|
|
||||||
- Admin users are exempt from the global max upload size on the homepage upload flow. Future
|
|
||||||
per-user quotas should apply to this same upload path rather than creating a second uploader.
|
|
||||||
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
|
||||||
user storage quota, and usage retention.
|
|
||||||
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
|
||||||
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
|
|
||||||
- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and
|
|
||||||
repeated login failures. Auto-ban is off by default and configured from the admin UI.
|
|
||||||
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
|
|
||||||
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
|
|
||||||
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
|
|
||||||
The bbolt database and JSON logs remain local under `./data/db` and `./data/logs`.
|
|
||||||
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
|
|
||||||
tokens, thumbnails, and cleanup continue to work as before.
|
|
||||||
|
|
||||||
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
|
||||||
support will power public forgot-password and optional email delivery.
|
|
||||||
|
|
||||||
## Runtime Data
|
|
||||||
|
|
||||||
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/db/warpbox.bbolt` stores manual bans, automatic ban settings, abuse counters, and malicious
|
|
||||||
path rules.
|
|
||||||
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -30,6 +30,9 @@ type Config struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
@@ -52,6 +55,11 @@ 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) {
|
||||||
@@ -75,6 +83,9 @@ func Load() (Config, error) {
|
|||||||
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),
|
||||||
@@ -94,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")
|
||||||
@@ -103,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) ||
|
||||||
|
|||||||
@@ -68,4 +68,13 @@ func TestLoadDefaultsUseLargeUploadFriendlyTimeouts(t *testing.T) {
|
|||||||
if cfg.WriteTimeout != 0 {
|
if cfg.WriteTimeout != 0 {
|
||||||
t.Fatalf("WriteTimeout = %s, want 0 for long uploads", cfg.WriteTimeout)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
settings.AnonymousUploadsEnabled = r.FormValue("anonymous_uploads_enabled") == "on"
|
settings.AnonymousUploadsEnabled = r.FormValue("anonymous_uploads_enabled") == "on"
|
||||||
|
settings.ResumableUploadsEnabled = r.FormValue("resumable_uploads_enabled") == "on"
|
||||||
if value := parsePositiveInt(r.FormValue("usage_retention_days")); value > 0 {
|
if value := parsePositiveInt(r.FormValue("usage_retention_days")); value > 0 {
|
||||||
settings.UsageRetentionDays = value
|
settings.UsageRetentionDays = value
|
||||||
}
|
}
|
||||||
@@ -604,6 +605,16 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
if value := parsePositiveInt(r.FormValue("short_window_seconds")); value > 0 {
|
if value := parsePositiveInt(r.FormValue("short_window_seconds")); value > 0 {
|
||||||
settings.ShortWindowSeconds = value
|
settings.ShortWindowSeconds = value
|
||||||
}
|
}
|
||||||
|
if value := parsePositiveFloat(r.FormValue("resumable_chunk_size_mb")); value > 0 {
|
||||||
|
settings.ResumableChunkSizeMB = value
|
||||||
|
}
|
||||||
|
if value := parsePositiveInt(r.FormValue("resumable_retention_hours")); value > 0 {
|
||||||
|
settings.ResumableRetentionHours = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(r.FormValue("resumable_chunk_mode")); value != "" {
|
||||||
|
settings.ResumableChunkMode = value
|
||||||
|
}
|
||||||
|
settings.ResumableChunkPath = strings.TrimSpace(r.FormValue("resumable_chunk_path"))
|
||||||
if value := r.FormValue("anonymous_storage_backend"); value != "" {
|
if value := r.FormValue("anonymous_storage_backend"); value != "" {
|
||||||
settings.AnonymousStorageBackend = value
|
settings.AnonymousStorageBackend = value
|
||||||
}
|
}
|
||||||
@@ -1770,7 +1781,7 @@ func isHealthCheckLogEntry(raw map[string]any) bool {
|
|||||||
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
if idx := strings.IndexByte(path, '?'); idx >= 0 {
|
||||||
path = path[:idx]
|
path = path[:idx]
|
||||||
}
|
}
|
||||||
return path == "/health" || path == "/healthz" || path == "/api/v1/health"
|
return path == "/health"
|
||||||
}
|
}
|
||||||
|
|
||||||
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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
|
||||||
@@ -39,7 +38,6 @@ func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
|||||||
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",
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ type App struct {
|
|||||||
banService *services.BanService
|
banService *services.BanService
|
||||||
rateLimiter *rateLimiter
|
rateLimiter *rateLimiter
|
||||||
uploadGroups *uploadGrouper
|
uploadGroups *uploadGrouper
|
||||||
|
fileIcons *fileIconSet
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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,
|
||||||
@@ -34,6 +39,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
|||||||
banService: banService,
|
banService: banService,
|
||||||
rateLimiter: newRateLimiter(),
|
rateLimiter: newRateLimiter(),
|
||||||
uploadGroups: newUploadGrouper(),
|
uploadGroups: newUploadGrouper(),
|
||||||
|
fileIcons: fileIcons,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,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)
|
||||||
@@ -126,15 +134,31 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
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.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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"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"
|
||||||
)
|
)
|
||||||
@@ -40,14 +41,26 @@ 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
|
ReactURL string
|
||||||
Reactions []reactionView
|
Reactions []reactionView
|
||||||
|
ReactionMore int
|
||||||
Reacted bool
|
Reacted bool
|
||||||
|
Processing bool
|
||||||
|
Failed bool
|
||||||
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
type reactionView struct {
|
type reactionView struct {
|
||||||
@@ -55,6 +68,7 @@ type reactionView struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type emojiTabView struct {
|
type emojiTabView struct {
|
||||||
@@ -96,6 +110,18 @@ 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)
|
visitorID := a.reactionVisitorID(w, r)
|
||||||
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,16 +141,38 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "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,
|
||||||
@@ -147,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. Open 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 {
|
||||||
@@ -154,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,
|
||||||
@@ -179,6 +314,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -188,12 +324,27 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
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")...)
|
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
|
||||||
@@ -202,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
|
||||||
@@ -219,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.
|
||||||
@@ -287,9 +626,11 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
|
|||||||
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 {
|
||||||
@@ -305,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 {
|
||||||
@@ -314,6 +688,7 @@ 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"))...)
|
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
|
||||||
@@ -330,9 +705,25 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
|||||||
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 {
|
||||||
@@ -350,18 +741,32 @@ func (a *App) fileView(box services.Box, file services.File) fileView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
|
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),
|
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
||||||
Reactions: a.reactionViews(reactions),
|
Reactions: reactionViews,
|
||||||
|
ReactionMore: reactionOverflowCount(reactionViews),
|
||||||
Reacted: reacted,
|
Reacted: reacted,
|
||||||
|
Processing: file.Processing,
|
||||||
|
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
|
||||||
|
Error: troubleReasonForLog(box, file),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,17 +811,25 @@ func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
|
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
|
||||||
views := make([]reactionView, 0, len(reactions))
|
views := make([]reactionView, 0, len(reactions))
|
||||||
for _, reaction := range reactions {
|
for index, reaction := range reactions {
|
||||||
views = append(views, reactionView{
|
views = append(views, reactionView{
|
||||||
EmojiID: reaction.EmojiID,
|
EmojiID: reaction.EmojiID,
|
||||||
URL: emojiURL(reaction.EmojiID),
|
URL: emojiURL(reaction.EmojiID),
|
||||||
Label: emojiLabel(reaction.EmojiID),
|
Label: emojiLabel(reaction.EmojiID),
|
||||||
Count: reaction.Count,
|
Count: reaction.Count,
|
||||||
|
Visible: index < 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reactionOverflowCount(reactions []reactionView) int {
|
||||||
|
if len(reactions) <= 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(reactions) - 2
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) emojiTabs() ([]emojiTabView, error) {
|
func (a *App) emojiTabs() ([]emojiTabView, error) {
|
||||||
root := a.emojiRoot()
|
root := a.emojiRoot()
|
||||||
entries, err := os.ReadDir(root)
|
entries, err := os.ReadDir(root)
|
||||||
@@ -564,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
@@ -57,14 +58,18 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
"actor", actor,
|
"actor", actor,
|
||||||
"user_id", user.ID,
|
"user_id", user.ID,
|
||||||
)...)
|
)...)
|
||||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
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 quickly. Drop a file, get a link.",
|
||||||
|
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,
|
||||||
@@ -95,7 +100,7 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -152,22 +157,25 @@ 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 {
|
||||||
@@ -177,5 +185,5 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
|
|||||||
if policy.MaxDays < 0 {
|
if policy.MaxDays < 0 {
|
||||||
expiryLimit = "no expiry limit."
|
expiryLimit = "no expiry limit."
|
||||||
}
|
}
|
||||||
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,17 @@ func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeFile(w, r, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -53,11 +54,16 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
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())...)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
files := uploadFiles(r)
|
files := uploadIncomingFiles(r)
|
||||||
totalBytes := totalUploadBytes(files)
|
totalBytes := totalUploadBytes(files)
|
||||||
var ownerID string
|
var ownerID string
|
||||||
var collectionID string
|
var collectionID string
|
||||||
@@ -159,7 +165,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
// uploadGroupWindow are folded into one box. Without the header the behaviour is
|
// 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
|
// 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.
|
// 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) {
|
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []services.IncomingFile, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
|
||||||
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
|
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
|
||||||
if batch == "" {
|
if batch == "" {
|
||||||
if enforceBoxLimits {
|
if enforceBoxLimits {
|
||||||
@@ -167,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
|||||||
return services.UploadResult{}, 0, status, message, nil
|
return services.UploadResult{}, 0, status, message, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, opts)
|
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services.UploadResult{}, 0, 0, "", err
|
return services.UploadResult{}, 0, 0, "", err
|
||||||
}
|
}
|
||||||
@@ -188,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
|||||||
|
|
||||||
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
|
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 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 {
|
if result, err := a.uploadService.AppendIncomingFiles(entry.boxID, files, opts); err == nil {
|
||||||
// Re-attach the manage/delete URLs from the box's creation so every
|
// Re-attach the manage/delete URLs from the box's creation so every
|
||||||
// upload in the batch returns a working deletion URL.
|
// upload in the batch returns a working deletion URL.
|
||||||
result.ManageURL = entry.manageURL
|
result.ManageURL = entry.manageURL
|
||||||
@@ -204,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
|||||||
return services.UploadResult{}, 0, status, message, nil
|
return services.UploadResult{}, 0, status, message, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, opts)
|
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services.UploadResult{}, 0, 0, "", err
|
return services.UploadResult{}, 0, 0, "", err
|
||||||
}
|
}
|
||||||
@@ -224,16 +230,27 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
|
|||||||
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
|
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 []services.IncomingFile, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
|
|||||||
return "ip:" + uploadClientIP(r)
|
return "ip:" + uploadClientIP(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
func totalUploadBytes(files []services.IncomingFile) int64 {
|
||||||
var total int64
|
var total int64
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
total += file.Size
|
total += file.Size()
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
@@ -393,13 +410,48 @@ func statusForDownloadError(err error) int {
|
|||||||
return http.StatusForbidden
|
return http.StatusForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadFiles(r *http.Request) []*multipart.FileHeader {
|
type namedMultipartFile struct {
|
||||||
|
header *multipart.FileHeader
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f namedMultipartFile) Name() string {
|
||||||
|
if strings.TrimSpace(f.name) != "" {
|
||||||
|
return f.name
|
||||||
|
}
|
||||||
|
return f.header.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f namedMultipartFile) Size() int64 {
|
||||||
|
return f.header.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f namedMultipartFile) ContentType() string {
|
||||||
|
return f.header.Header.Get("Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f namedMultipartFile) Open() (io.ReadCloser, error) {
|
||||||
|
return f.header.Open()
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadIncomingFiles(r *http.Request) []services.IncomingFile {
|
||||||
if r.MultipartForm == nil {
|
if r.MultipartForm == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
files := make([]*multipart.FileHeader, 0)
|
fileHeaders := r.MultipartForm.File["file"]
|
||||||
files = append(files, r.MultipartForm.File["file"]...)
|
shareXHeaders := r.MultipartForm.File["sharex"]
|
||||||
files = append(files, r.MultipartForm.File["sharex"]...)
|
paths := r.MultipartForm.Value["file_path"]
|
||||||
|
files := make([]services.IncomingFile, 0, len(fileHeaders)+len(shareXHeaders))
|
||||||
|
for index, header := range fileHeaders {
|
||||||
|
name := ""
|
||||||
|
if index < len(paths) {
|
||||||
|
name = paths[index]
|
||||||
|
}
|
||||||
|
files = append(files, namedMultipartFile{header: header, name: name})
|
||||||
|
}
|
||||||
|
for _, header := range shareXHeaders {
|
||||||
|
files = append(files, namedMultipartFile{header: header})
|
||||||
|
}
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -10,10 +11,13 @@ 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/jobs"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
@@ -103,6 +107,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, "Open 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()
|
||||||
@@ -221,6 +764,9 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
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,
|
||||||
@@ -228,6 +774,10 @@ 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)
|
||||||
@@ -268,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
t.Fatalf("NewBanService returned error: %v", err)
|
t.Fatalf("NewBanService returned error: %v", err)
|
||||||
}
|
}
|
||||||
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
|
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
|
||||||
|
jobs.WaitForThumbnailJobs()
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -275,8 +826,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)
|
||||||
@@ -342,6 +897,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 {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ 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 {
|
if banService != nil {
|
||||||
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
|
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,7 +45,12 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ func (s *BanService) MaliciousPattern(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldSkipMaliciousPath(path string) bool {
|
func shouldSkipMaliciousPath(path string) bool {
|
||||||
return path == "/health" || path == "/healthz" || path == "/api/v1/health" || strings.HasPrefix(path, "/static/")
|
return path == "/health" || strings.HasPrefix(path, "/static/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {
|
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {
|
||||||
|
|||||||
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: cleanUploadDisplayName(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 = cleanUploadDisplayName(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) + "|" + cleanUploadDisplayName(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))
|
||||||
|
}
|
||||||
@@ -37,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 {
|
||||||
@@ -89,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)
|
||||||
@@ -143,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,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
|
||||||
})
|
})
|
||||||
@@ -217,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,6 +43,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 +53,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 +118,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,11 +131,48 @@ 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"`
|
||||||
@@ -98,6 +190,7 @@ type ResultFile struct {
|
|||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
ThumbnailURL string `json:"thumbnailUrl"`
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
|
Processing bool `json:"processing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminStats struct {
|
type AdminStats struct {
|
||||||
@@ -198,6 +291,14 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -232,13 +333,16 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
if err := s.writeIncomingFilesToBox(ctx, &box, files, opts); err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +365,10 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
// selection into a single box). The box keeps its original expiry, password and
|
// selection into a single box). The box keeps its original expiry, password and
|
||||||
// other settings; only the new files are written.
|
// other settings; only the new files are written.
|
||||||
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
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 {
|
if len(files) == 0 {
|
||||||
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
||||||
}
|
}
|
||||||
@@ -268,7 +376,7 @@ func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
if err := s.writeIncomingFilesToBox(context.Background(), &box, files, opts); err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
if err := s.SaveBox(box); err != nil {
|
if err := s.SaveBox(box); err != nil {
|
||||||
@@ -289,14 +397,26 @@ func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader,
|
|||||||
// appends the file metadata to box.Files. The box's StorageBackendID determines
|
// appends the file metadata to box.Files. The box's StorageBackendID determines
|
||||||
// where files land, so it works for both new and existing boxes.
|
// where files land, so it works for both new and existing boxes.
|
||||||
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
|
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)
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, header := range files {
|
for _, incoming := range files {
|
||||||
if !opts.SkipSizeLimit {
|
if !opts.SkipSizeLimit {
|
||||||
if err := s.ValidateSize(header.Size); err != nil {
|
if err := s.ValidateSize(incoming.Size()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,16 +426,16 @@ func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader,
|
|||||||
maxSize = 0
|
maxSize = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := header.Open()
|
file, err := incoming.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fileID := randomID(8)
|
fileID := randomID(8)
|
||||||
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
|
||||||
objectKey := boxObjectKey(box.ID, storedName)
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
contentType := header.Header.Get("Content-Type")
|
contentType := incoming.ContentType()
|
||||||
if contentType == "" {
|
if contentType == "" || contentType == "application/octet-stream" {
|
||||||
buffer := make([]byte, 512)
|
buffer := make([]byte, 512)
|
||||||
n, _ := file.Read(buffer)
|
n, _ := file.Read(buffer)
|
||||||
contentType = http.DetectContentType(buffer[:n])
|
contentType = http.DetectContentType(buffer[:n])
|
||||||
@@ -324,17 +444,18 @@ func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
|
_ = backend.Delete(context.Background(), objectKey)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
file.Close()
|
file.Close()
|
||||||
|
|
||||||
box.Files = append(box.Files, File{
|
box.Files = append(box.Files, File{
|
||||||
ID: fileID,
|
ID: fileID,
|
||||||
Name: filepath.Base(header.Filename),
|
Name: cleanUploadDisplayName(incoming.Name()),
|
||||||
StoredName: storedName,
|
StoredName: storedName,
|
||||||
Size: header.Size,
|
Size: incoming.Size(),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
PreviewKind: previewKind(contentType),
|
PreviewKind: previewKind(contentType),
|
||||||
ObjectKey: objectKey,
|
ObjectKey: objectKey,
|
||||||
@@ -344,6 +465,36 @@ func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanUploadDisplayName(name string) string {
|
||||||
|
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
|
||||||
|
clean = strings.TrimLeft(clean, "/")
|
||||||
|
clean = path.Clean(clean)
|
||||||
|
if clean == "." || clean == "/" || clean == "" {
|
||||||
|
return "download"
|
||||||
|
}
|
||||||
|
parts := strings.Split(clean, "/")
|
||||||
|
safeParts := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" || part == "." || part == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
part = strings.Map(func(r rune) rune {
|
||||||
|
if r < 0x20 || r == 0x7f || r == '/' || r == '\\' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, part)
|
||||||
|
if part != "" {
|
||||||
|
safeParts = append(safeParts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(safeParts) == 0 {
|
||||||
|
return "download"
|
||||||
|
}
|
||||||
|
return strings.Join(safeParts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -648,6 +799,12 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
|
|||||||
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
||||||
_ = backend.Delete(context.Background(), 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:]...)
|
box.Files = append(box.Files[:index], box.Files[index+1:]...)
|
||||||
@@ -735,7 +892,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
|
||||||
@@ -755,6 +935,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 {
|
||||||
@@ -857,6 +1061,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
|
||||||
}
|
}
|
||||||
@@ -866,10 +1077,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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,6 +1090,7 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
|||||||
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),
|
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
|
||||||
|
Processing: file.Processing,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,21 +1140,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 {
|
||||||
|
|||||||
@@ -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,6 +7,9 @@ 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
|
||||||
@@ -18,9 +21,16 @@ type PageData struct {
|
|||||||
AppName string
|
AppName string
|
||||||
AppVersion 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
|
||||||
|
|||||||
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 |
81
backend/static/api/warpbox.ps1
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
#requires -version 5
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
warpbox: command line uploader for Warpbox
|
||||||
|
.DESCRIPTION
|
||||||
|
Set the server once, then upload anything:
|
||||||
|
setx WARPBOX_HOST "https://your.warpbox.host"
|
||||||
|
warpbox .\report.pdf
|
||||||
|
|
||||||
|
Install (PowerShell):
|
||||||
|
iwr "$env:WARPBOX_HOST/static/api/warpbox.ps1" -OutFile $HOME\warpbox.ps1
|
||||||
|
# add a function to your $PROFILE: function warpbox { & "$HOME\warpbox.ps1" @args }
|
||||||
|
|
||||||
|
Auth: set the token once so it never lands in your command history.
|
||||||
|
setx WARPBOX_TOKEN "wbx_your_token"
|
||||||
|
Create a token under Account, Access tokens.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\warpbox.ps1 .\report.pdf
|
||||||
|
.EXAMPLE
|
||||||
|
.\warpbox.ps1 -Password 123 -Expiry 2d .\photo.png .\clip.mp4
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Alias('p')][string]$Password,
|
||||||
|
[Alias('e')][string]$Expiry,
|
||||||
|
[Alias('n')][int]$MaxDownloads,
|
||||||
|
[Alias('o')][switch]$Obfuscate,
|
||||||
|
[string]$Server = $env:WARPBOX_HOST,
|
||||||
|
[string]$Auth = $env:WARPBOX_TOKEN,
|
||||||
|
[string]$AuthFile,
|
||||||
|
[switch]$Json,
|
||||||
|
[switch]$Help,
|
||||||
|
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Files
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Help -or -not $Files) {
|
||||||
|
Write-Host 'warpbox: upload files to Warpbox'
|
||||||
|
Write-Host 'USAGE: warpbox.ps1 [-Password pw] [-Expiry 2d] [-MaxDownloads n] [-Obfuscate] [-Json] <file> [file ...]'
|
||||||
|
Write-Host 'SERVER: set WARPBOX_HOST in your environment (setx WARPBOX_HOST "https://your.host")'
|
||||||
|
Write-Host 'AUTH: set WARPBOX_TOKEN in your environment (setx WARPBOX_TOKEN "wbx_...")'
|
||||||
|
if (-not $Files -and -not $Help) { exit 2 } else { exit 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Server) {
|
||||||
|
Write-Error 'warpbox: no server set. Use -Server <url> or set WARPBOX_HOST'
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
if ($AuthFile) { $Auth = (Get-Content -Raw $AuthFile).Trim() }
|
||||||
|
|
||||||
|
function ConvertTo-Minutes($v) {
|
||||||
|
if ($v -match '^(\d+)([mhdw]?)$') {
|
||||||
|
$n = [int]$Matches[1]
|
||||||
|
switch ($Matches[2]) {
|
||||||
|
'h' { return $n * 60 }
|
||||||
|
'd' { return $n * 1440 }
|
||||||
|
'w' { return $n * 10080 }
|
||||||
|
default { return $n }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $v
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expand wildcards (PowerShell does not expand them in arguments).
|
||||||
|
$expanded = @()
|
||||||
|
foreach ($f in $Files) {
|
||||||
|
$hits = Get-ChildItem -Path $f -File -ErrorAction SilentlyContinue
|
||||||
|
if ($hits) { $expanded += $hits.FullName } else { $expanded += $f }
|
||||||
|
}
|
||||||
|
|
||||||
|
$curlArgs = @('-fS')
|
||||||
|
foreach ($f in $expanded) { $curlArgs += @('-F', "file=@$f") }
|
||||||
|
if ($Password) { $curlArgs += @('-F', "password=$Password") }
|
||||||
|
if ($Expiry) { $curlArgs += @('-F', "expires_minutes=$(ConvertTo-Minutes $Expiry)") }
|
||||||
|
if ($MaxDownloads) { $curlArgs += @('-F', "max_downloads=$MaxDownloads") }
|
||||||
|
if ($Obfuscate) { $curlArgs += @('-F', 'obfuscate_metadata=on') }
|
||||||
|
if ($Auth) { $curlArgs += @('-H', "Authorization: Bearer $Auth") }
|
||||||
|
if ($Json) { $curlArgs += @('-H', 'Accept: application/json') }
|
||||||
|
$curlArgs += "$($Server.TrimEnd('/'))/api/v1/upload"
|
||||||
|
|
||||||
|
& curl.exe @curlArgs
|
||||||
120
backend/static/api/warpbox.sh
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# warpbox: command line uploader for Warpbox
|
||||||
|
#
|
||||||
|
# Set the server once, then upload anything:
|
||||||
|
# export WARPBOX_HOST=https://your.warpbox.host
|
||||||
|
# warpbox ./report.pdf
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# curl -fsSL "$WARPBOX_HOST/static/api/warpbox.sh" -o ~/.local/bin/warpbox
|
||||||
|
# chmod +x ~/.local/bin/warpbox
|
||||||
|
# # make sure ~/.local/bin is on your PATH
|
||||||
|
#
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
WARPBOX_HOST="${WARPBOX_HOST:-}"
|
||||||
|
AUTH="${WARPBOX_TOKEN:-}"
|
||||||
|
PASSWORD=""
|
||||||
|
EXPIRY=""
|
||||||
|
MAX_DOWNLOADS=""
|
||||||
|
OBFUSCATE=""
|
||||||
|
AS_JSON=0
|
||||||
|
FILES=()
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
warpbox: upload files to Warpbox from the terminal
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
warpbox [options] <file> [file ...]
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
-p, --password <pw> Require a password to view/download the box
|
||||||
|
-e, --expiry <dur> Lifetime before expiry: 30m, 6h, 2d, 1w (or bare minutes)
|
||||||
|
-n, --max-downloads <n> Expire after N downloads
|
||||||
|
-o, --obfuscate Hide file names/counts until unlocked (needs --password)
|
||||||
|
--host <url> Warpbox server to upload to (or set WARPBOX_HOST)
|
||||||
|
--auth <token> API token (prefer the WARPBOX_TOKEN env var, see AUTH)
|
||||||
|
--auth-file <path> Read the API token from a file (safer than --auth)
|
||||||
|
--json Print the full JSON response instead of just the URL
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
AUTH:
|
||||||
|
Uploads are anonymous unless a token is supplied. The most secure option is the
|
||||||
|
WARPBOX_TOKEN environment variable, so the token never lands in your shell
|
||||||
|
history or the process list:
|
||||||
|
|
||||||
|
export WARPBOX_TOKEN=wbx_your_token
|
||||||
|
warpbox ./photo.png
|
||||||
|
|
||||||
|
Create a token under Account, Access tokens. Avoid --auth on shared machines.
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
warpbox ./report.pdf
|
||||||
|
warpbox --password 123 --expiry 2d ./first_file.zip ./whatever.png ./all_*_photos.jpg
|
||||||
|
warpbox --max-downloads 5 --json ./build.zip
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry_to_minutes() {
|
||||||
|
local v="$1" num unit
|
||||||
|
num="${v%%[mhdw]*}"
|
||||||
|
unit="${v##*[0-9]}"
|
||||||
|
case "$unit" in
|
||||||
|
h) echo $(( num * 60 )) ;;
|
||||||
|
d) echo $(( num * 1440 )) ;;
|
||||||
|
w) echo $(( num * 10080 )) ;;
|
||||||
|
m|"") echo "$num" ;;
|
||||||
|
*) echo "$num" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-p|--password) PASSWORD="$2"; shift 2 ;;
|
||||||
|
-e|--expiry) EXPIRY="$2"; shift 2 ;;
|
||||||
|
-n|--max-downloads) MAX_DOWNLOADS="$2"; shift 2 ;;
|
||||||
|
-o|--obfuscate) OBFUSCATE="on"; shift ;;
|
||||||
|
--host) WARPBOX_HOST="$2"; shift 2 ;;
|
||||||
|
--auth) AUTH="$2"; shift 2 ;;
|
||||||
|
--auth-file) AUTH="$(cat "$2")"; shift 2 ;;
|
||||||
|
--json) AS_JSON=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
--) shift; while [ $# -gt 0 ]; do FILES+=("$1"); shift; done ;;
|
||||||
|
-*) echo "warpbox: unknown option $1" >&2; exit 2 ;;
|
||||||
|
*) FILES+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$WARPBOX_HOST" ]; then
|
||||||
|
echo "warpbox: no server set. Use --host <url> or export WARPBOX_HOST=<url>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#FILES[@]} -eq 0 ]; then
|
||||||
|
echo "warpbox: no files given" >&2
|
||||||
|
echo >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURL_ARGS=()
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
if [ ! -f "$f" ]; then
|
||||||
|
echo "warpbox: not a file: $f" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
CURL_ARGS+=(-F "file=@${f}")
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$PASSWORD" ] && CURL_ARGS+=(-F "password=${PASSWORD}")
|
||||||
|
[ -n "$EXPIRY" ] && CURL_ARGS+=(-F "expires_minutes=$(expiry_to_minutes "$EXPIRY")")
|
||||||
|
[ -n "$MAX_DOWNLOADS" ] && CURL_ARGS+=(-F "max_downloads=${MAX_DOWNLOADS}")
|
||||||
|
[ -n "$OBFUSCATE" ] && CURL_ARGS+=(-F "obfuscate_metadata=on")
|
||||||
|
|
||||||
|
HEADERS=()
|
||||||
|
[ -n "$AUTH" ] && HEADERS+=(-H "Authorization: Bearer ${AUTH}")
|
||||||
|
[ "$AS_JSON" -eq 1 ] && HEADERS+=(-H "Accept: application/json")
|
||||||
|
|
||||||
|
exec curl -fS "${HEADERS[@]}" "${CURL_ARGS[@]}" "${WARPBOX_HOST%/}/api/v1/upload"
|
||||||
BIN
backend/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,16 +152,16 @@
|
|||||||
|
|
||||||
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
|
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
|
||||||
are styled as their own Win98 controls below, so they're excluded here. */
|
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) {
|
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill) {
|
||||||
color: #0000ee;
|
color: #0000ee;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited {
|
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):visited {
|
||||||
color: #551a8b;
|
color: #551a8b;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover {
|
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):hover {
|
||||||
color: #ee0000;
|
color: #ee0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,31 +592,432 @@
|
|||||||
content: "\23F1 ";
|
content: "\23F1 ";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat
|
/* The file browser becomes a Win98 Explorer window: blue titlebar, grey
|
||||||
buttons that raise on hover and depress when active. */
|
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 {
|
:root[data-theme="retro"] .view-toolbar {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-top: 1rem;
|
margin-top: 0;
|
||||||
padding: 3px;
|
padding: 0;
|
||||||
background: #c0c0c0;
|
background: #c0c0c0;
|
||||||
border: 1px solid #000000;
|
border: 0;
|
||||||
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="retro"] .view-toolbar .button {
|
:root[data-theme="retro"] .view-toolbar .button,
|
||||||
|
:root[data-theme="retro"] .file-browser-toolbar > .button {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="retro"] .view-toolbar .button:hover {
|
: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;
|
background: #c0c0c0;
|
||||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
|
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"] .view-toolbar .button.is-active,
|
||||||
|
:root[data-theme="retro"] .file-browser-toolbar > .button.is-active {
|
||||||
background: #d4d0c8;
|
background: #d4d0c8;
|
||||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------- */
|
||||||
|
/* API documentation: sidebar + panels as Win98 windows */
|
||||||
|
/* The new .api-docs layout uses dark revamp tokens by default, which are */
|
||||||
|
/* unreadable on the black retro desktop. Re-skin it as Win98 chrome: a */
|
||||||
|
/* raised silver sidebar window, plain light section intros on the desktop, */
|
||||||
|
/* and each card a silver window with a navy title bar from its heading. */
|
||||||
|
/* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Sidebar = raised silver window with a real title bar from its <h1>. */
|
||||||
|
:root[data-theme="retro"] .api-sidebar {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-sidebar > .kicker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-sidebar-title {
|
||||||
|
margin: -0.5rem -0.5rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-nav {
|
||||||
|
border-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nav entries are flat silver list items; the active one is a navy bar. */
|
||||||
|
:root[data-theme="retro"] .api-nav-link {
|
||||||
|
color: #000000;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-nav-link:hover {
|
||||||
|
background: #d4d0c8;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-nav-link.is-active {
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-sidebar-meta {
|
||||||
|
border-top: 1px solid #808080;
|
||||||
|
box-shadow: 0 -1px 0 #ffffff;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section intro becomes a real Win98 window: silver body, the <h2> a navy
|
||||||
|
title bar with a fake close button, and the subtitle as black body text.
|
||||||
|
This fixes the default black-on-black inline code in headings/intros. */
|
||||||
|
:root[data-theme="retro"] .panel-head {
|
||||||
|
max-width: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The kicker is redundant once the title sits in a title bar; hide it so the
|
||||||
|
bar can hug the top edge (the markup puts the kicker before the h2). */
|
||||||
|
:root[data-theme="retro"] .panel-head .kicker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .panel-head h2 {
|
||||||
|
position: relative;
|
||||||
|
margin: -1rem -1rem 1rem;
|
||||||
|
padding: 0.35rem 1.8rem 0.35rem 0.5rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code in the title (e.g. "The warpbox CLI") reads white on the bar
|
||||||
|
instead of the default black. */
|
||||||
|
:root[data-theme="retro"] .panel-head h2 code {
|
||||||
|
color: #ffffff;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .panel-head h2::after {
|
||||||
|
content: "\2715";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.4rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1rem;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .panel-head .lead {
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code in the subtitle: sunken white field, black text. */
|
||||||
|
:root[data-theme="retro"] .panel-head .lead code {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The lone "Quick links" label on the home desktop stays light. */
|
||||||
|
:root[data-theme="retro"] .section-label {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ShareX step lists are light-muted by default; black on the silver window. */
|
||||||
|
:root[data-theme="retro"] .docs-steps {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each card heading becomes a Win98 title bar with a fake close button.
|
||||||
|
Headings bleed to the window edges; only the first hugs the top edge so a
|
||||||
|
multi-step card (e.g. ShareX) reads as stacked group bars, not overlaps. */
|
||||||
|
:root[data-theme="retro"] .api-content .card > .card-content > h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1.5rem -1.5rem 1rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-content .card > .card-content > h3:first-child {
|
||||||
|
margin-top: -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The upload endpoint card leads with a method + path row; make that the bar. */
|
||||||
|
:root[data-theme="retro"] .api-content .endpoint-head {
|
||||||
|
margin: -1.5rem -1.5rem 1rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .endpoint-head .endpoint-path {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .api-content .card > .card-content > h3::after,
|
||||||
|
:root[data-theme="retro"] .api-content .endpoint-head::after {
|
||||||
|
content: "\2715";
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-left: auto;
|
||||||
|
background: #c0c0c0;
|
||||||
|
color: #000000;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body text inside windows reads black, not muted purple. */
|
||||||
|
:root[data-theme="retro"] .api-content .card p,
|
||||||
|
:root[data-theme="retro"] .api-content .card h4,
|
||||||
|
:root[data-theme="retro"] .api-content .field-grid span,
|
||||||
|
:root[data-theme="retro"] .endpoint-list div em,
|
||||||
|
:root[data-theme="retro"] .faq-item summary,
|
||||||
|
:root[data-theme="retro"] .faq-item p {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sub-labels (Request fields, Example, ...) become small black headers. */
|
||||||
|
:root[data-theme="retro"] .api-content .card h4 {
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Endpoint rows are sunken white fields. */
|
||||||
|
:root[data-theme="retro"] .endpoint-list div {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Home shortcut tiles and quick links: silver windows / sunken white fields. */
|
||||||
|
:root[data-theme="retro"] .shortcut-card {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 6%, #c0c0c0 10%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .shortcut-card:hover {
|
||||||
|
transform: none;
|
||||||
|
background-color: #d4d0c8;
|
||||||
|
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"] .shortcut-eyebrow {
|
||||||
|
color: #000078;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .shortcut-title,
|
||||||
|
:root[data-theme="retro"] .shortcut-sub {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .link-pill {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .link-pill span {
|
||||||
|
background: #000078;
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colour-coded badges in the classic 16-colour VGA palette, with black
|
||||||
|
borders so they read like little Win98 toolbar icons. */
|
||||||
|
:root[data-theme="retro"] .link-pill .tag-get { background: #0000aa; color: #ffffff; }
|
||||||
|
:root[data-theme="retro"] .link-pill .tag-post { background: #008000; color: #ffffff; }
|
||||||
|
:root[data-theme="retro"] .link-pill .tag-json { background: #aa00aa; color: #ffffff; }
|
||||||
|
:root[data-theme="retro"] .link-pill .tag-key { background: #aa5500; color: #ffffff; }
|
||||||
|
:root[data-theme="retro"] .link-pill .tag-help { background: #00aaaa; color: #000000; }
|
||||||
|
|
||||||
|
/* CLI download cards = silver windows. */
|
||||||
|
:root[data-theme="retro"] .download-card {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .download-card .download-os,
|
||||||
|
:root[data-theme="retro"] .download-card p {
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FAQ entries are silver windows; the +/- marker stays. */
|
||||||
|
:root[data-theme="retro"] .faq-item {
|
||||||
|
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||||
|
background-color: #c0c0c0;
|
||||||
|
border: 1px solid #000000;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] .faq-item summary::after {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy buttons: stay visible (retro already paints them as silver buttons). */
|
||||||
|
:root[data-theme="retro"] .code-block .copy-btn {
|
||||||
|
background: #c0c0c0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
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,
|
||||||
|
|||||||
@@ -10,6 +10,425 @@
|
|||||||
padding: 2rem 0 3rem;
|
padding: 2rem 0 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
API documentation — sidebar layout
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.api-docs {
|
||||||
|
width: min(74rem, calc(100% - 2rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 0 3rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar-title {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border-radius: calc(var(--radius) - 0.3rem);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-nav-link:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-nav-link.is-active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar-meta {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar-meta a {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Panels: only one visible at a time --- */
|
||||||
|
.api-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-panel {
|
||||||
|
display: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-panel.is-active {
|
||||||
|
display: block;
|
||||||
|
animation: doc-fade 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes doc-fade {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head .lead {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-content .card + .card,
|
||||||
|
.api-content .quickstart {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-content h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-content h4 {
|
||||||
|
margin: 1.4rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-content .card p {
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-content code {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-content .field-grid p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
margin: 1.75rem 0 0.75rem !important;
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Home shortcuts --- */
|
||||||
|
.shortcut-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent-c, var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-card:hover {
|
||||||
|
border-color: var(--accent-c, var(--ring));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent-c, var(--ring)) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-card accent. Each home shortcut owns a colour, echoed by its eyebrow,
|
||||||
|
left edge, and hover glow. */
|
||||||
|
.accent-blue { --accent-c: #3b82f6; }
|
||||||
|
.accent-green { --accent-c: #22c55e; }
|
||||||
|
.accent-violet { --accent-c: #8b5cf6; }
|
||||||
|
.accent-amber { --accent-c: #f59e0b; }
|
||||||
|
|
||||||
|
.shortcut-eyebrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent-c, var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-sub {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.2rem);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-pill:hover {
|
||||||
|
border-color: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-pill span {
|
||||||
|
flex: none;
|
||||||
|
min-width: 2.6rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colour-coded tags on the home quick links (and reusable elsewhere). Tinted
|
||||||
|
background plus a saturated label so they read as accents, not loud chips. */
|
||||||
|
.link-pill .tag-get { background: color-mix(in srgb, #3b82f6 22%, transparent); color: #93c5fd; }
|
||||||
|
.link-pill .tag-post { background: color-mix(in srgb, #22c55e 22%, transparent); color: #86efac; }
|
||||||
|
.link-pill .tag-json { background: color-mix(in srgb, #8b5cf6 24%, transparent); color: #c4b5fd; }
|
||||||
|
.link-pill .tag-key { background: color-mix(in srgb, #eab308 24%, transparent); color: #fde047; }
|
||||||
|
.link-pill .tag-help { background: color-mix(in srgb, #06b6d4 24%, transparent); color: #67e8f9; }
|
||||||
|
|
||||||
|
/* --- Code blocks with copy button --- */
|
||||||
|
.code-block {
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block .copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: color-mix(in srgb, var(--card) 80%, transparent);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block:hover .copy-btn,
|
||||||
|
.code-block .copy-btn:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block .copy-btn:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: var(--ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Endpoint blocks --- */
|
||||||
|
.endpoint-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-path {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method {
|
||||||
|
flex: none;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-get { background: #2563eb; }
|
||||||
|
.method-post { background: #16a34a; }
|
||||||
|
.method-put { background: #d97706; }
|
||||||
|
|
||||||
|
.endpoint-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.3rem);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div code {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div em {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- CLI download cards --- */
|
||||||
|
.download-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-card .download-os {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-card .button {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- FAQ --- */
|
||||||
|
.faq-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 0.2rem);
|
||||||
|
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary {
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
list-style: none;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary::after {
|
||||||
|
content: "+";
|
||||||
|
position: absolute;
|
||||||
|
right: 0.1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item[open] summary::after {
|
||||||
|
content: "\2212";
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item p {
|
||||||
|
margin: 0 0 0.95rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-header {
|
.docs-header {
|
||||||
max-width: 44rem;
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
@@ -63,42 +482,19 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.endpoint-list,
|
|
||||||
.field-grid {
|
.field-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
}
|
|
||||||
|
|
||||||
.endpoint-list div,
|
|
||||||
.field-grid {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.endpoint-list div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 7rem minmax(0, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.endpoint-list dt,
|
|
||||||
.endpoint-list dd {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.endpoint-list dt,
|
|
||||||
.field-grid span {
|
.field-grid span {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.endpoint-list dd code {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docs-steps {
|
.docs-steps {
|
||||||
margin: 0.85rem 0 0;
|
margin: 0.85rem 0 0;
|
||||||
padding-left: 1.1rem;
|
padding-left: 1.1rem;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -57,6 +57,44 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-docs {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar-title {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-nav {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-nav-link {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-sidebar-meta {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-list div em {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
position: static;
|
position: static;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -95,6 +133,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;
|
||||||
}
|
}
|
||||||
@@ -213,6 +286,54 @@
|
|||||||
flex-wrap: wrap;
|
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-actions,
|
||||||
.file-browser.is-thumbs .file-actions {
|
.file-browser.is-thumbs .file-actions {
|
||||||
width: 100%;
|
width: 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 |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<path d="M5.113.006c-2.803 0-5.074 2.273-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.903-20.31h-31.945z" fill-rule="evenodd" clip-rule="evenodd" fill="#00A1EE"/>
|
||||||
|
|
||||||
|
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
|
||||||
|
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#0089E9"/>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<path d="M5.151-.036c-2.803 0-5.074 2.272-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.774c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.902-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#F9CA06"/>
|
||||||
|
|
||||||
|
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
|
||||||
|
<path d="M56.008 20.316v1h-12.799s-6.312-1.26-6.129-6.708c0 0 .208 5.708 6.004 5.708h12.924z" fill="#F7BC04"/>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<path d="M5.15.011c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.272 5.074 5.072 5.074h45.775c2.802 0 5.075-2.271 5.075-5.074v-38.606l-18.904-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8E4C9E"/>
|
||||||
|
|
||||||
|
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
|
||||||
|
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#713985"/>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<path d="M5.111.006c-2.801 0-5.072 2.272-5.072 5.074v53.841c0 2.803 2.271 5.074 5.072 5.074h45.775c2.801 0 5.074-2.271 5.074-5.074v-38.606l-18.903-20.309h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#496AB3"/>
|
||||||
|
|
||||||
|
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
|
||||||
|
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#374FA0"/>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<g fill-rule="evenodd">
|
||||||
|
|
||||||
|
<path d="m5.11 0a5.07 5.07 0 0 0 -5.11 5v53.88a5.07 5.07 0 0 0 5.11 5.12h45.78a5.07 5.07 0 0 0 5.11-5.12v-38.6l-18.94-20.28z" fill="#107cad"/>
|
||||||
|
|
||||||
|
<path d="m56 20.35v1h-12.82s-6.31-1.26-6.13-6.71c0 0 .21 5.71 6 5.71z" fill="#084968"/>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="-4 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
||||||
|
<path d="M5.113-.026c-2.803 0-5.074 2.272-5.074 5.074v53.841c0 2.803 2.271 5.074 5.074 5.074h45.773c2.801 0 5.074-2.271 5.074-5.074v-38.605l-18.901-20.31h-31.946z" fill-rule="evenodd" clip-rule="evenodd" fill="#8199AF"/>
|
||||||
|
|
||||||
|
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||||
|
|
||||||
|
<path d="M55.977 20.352v1h-12.799s-6.312-1.26-6.129-6.707c0 0 .208 5.707 6.004 5.707h12.924z" fill="#617F9B"/>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
6
backend/static/humans.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* TEAM */
|
||||||
|
Built by: Danlegt
|
||||||
|
|
||||||
|
/* SITE */
|
||||||
|
Language: English
|
||||||
|
Software: Warp Box
|
||||||
@@ -5,6 +5,17 @@
|
|||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.Warpbox.absoluteURL = function absoluteURL(url) {
|
||||||
|
if (!url) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(url, window.location.origin).href;
|
||||||
|
} catch (_) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.Warpbox.writeClipboard = async function writeClipboard(text) {
|
window.Warpbox.writeClipboard = async function writeClipboard(text) {
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -26,6 +37,9 @@
|
|||||||
if (!text || !button) {
|
if (!text || !button) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (typeof text === "string" && (text.startsWith("/") || /^https?:\/\//i.test(text))) {
|
||||||
|
text = window.Warpbox.absoluteURL(text);
|
||||||
|
}
|
||||||
await window.Warpbox.writeClipboard(text);
|
await window.Warpbox.writeClipboard(text);
|
||||||
const previous = button.textContent;
|
const previous = button.textContent;
|
||||||
button.textContent = copiedLabel;
|
button.textContent = copiedLabel;
|
||||||
|
|||||||
43
backend/static/js/02-pwa.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
(function () {
|
||||||
|
let installPrompt = null;
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/service-worker.js").catch(() => {
|
||||||
|
/* Service workers are progressive enhancement here. */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("beforeinstallprompt", (event) => {
|
||||||
|
const button = document.querySelector("[data-install-pwa]");
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
installPrompt = event;
|
||||||
|
button.hidden = false;
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (!installPrompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await installPrompt.prompt();
|
||||||
|
await installPrompt.userChoice;
|
||||||
|
} finally {
|
||||||
|
installPrompt = null;
|
||||||
|
button.hidden = true;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("appinstalled", () => {
|
||||||
|
const button = document.querySelector("[data-install-pwa]");
|
||||||
|
if (button) {
|
||||||
|
button.hidden = true;
|
||||||
|
}
|
||||||
|
installPrompt = null;
|
||||||
|
});
|
||||||
|
})();
|
||||||
174
backend/static/js/03-popups.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
(function () {
|
||||||
|
const DEFAULT_DURATION = 6200;
|
||||||
|
const VARIANTS = ["info", "warning", "error"];
|
||||||
|
const GENERIC_ERROR_MESSAGE = "Something went wrong on this page. Please try again in a moment.";
|
||||||
|
|
||||||
|
window.Warpbox = window.Warpbox || {};
|
||||||
|
let lastGlobalErrorAt = 0;
|
||||||
|
|
||||||
|
function ensureRegion() {
|
||||||
|
let region = document.querySelector("[data-warpbox-popups]");
|
||||||
|
if (region) {
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
region = document.createElement("div");
|
||||||
|
region.className = "warpbox-popups";
|
||||||
|
region.setAttribute("data-warpbox-popups", "");
|
||||||
|
region.setAttribute("aria-live", "polite");
|
||||||
|
region.setAttribute("aria-atomic", "false");
|
||||||
|
document.body.append(region);
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(options, message) {
|
||||||
|
if (typeof options === "string") {
|
||||||
|
options = { message: options };
|
||||||
|
} else {
|
||||||
|
options = options || {};
|
||||||
|
}
|
||||||
|
if (message) {
|
||||||
|
options.message = message;
|
||||||
|
}
|
||||||
|
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
|
||||||
|
return {
|
||||||
|
variant,
|
||||||
|
title: options.title || defaultTitle(variant),
|
||||||
|
message: options.message || "",
|
||||||
|
duration: Number.isFinite(options.duration) ? options.duration : DEFAULT_DURATION,
|
||||||
|
actions: Array.isArray(options.actions) ? options.actions : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTitle(variant) {
|
||||||
|
if (variant === "error") {
|
||||||
|
return "Error";
|
||||||
|
}
|
||||||
|
if (variant === "warning") {
|
||||||
|
return "Warning";
|
||||||
|
}
|
||||||
|
return "Info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(options, message) {
|
||||||
|
const config = normalizeOptions(options, message);
|
||||||
|
const region = ensureRegion();
|
||||||
|
const popup = document.createElement("section");
|
||||||
|
popup.className = "warpbox-popup warpbox-popup-" + config.variant;
|
||||||
|
popup.setAttribute("role", config.variant === "error" ? "alert" : "status");
|
||||||
|
|
||||||
|
const chrome = document.createElement("div");
|
||||||
|
chrome.className = "warpbox-popup-chrome";
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "warpbox-popup-icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "warpbox-popup-body";
|
||||||
|
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.className = "warpbox-popup-title";
|
||||||
|
title.textContent = config.title;
|
||||||
|
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.className = "warpbox-popup-message";
|
||||||
|
text.textContent = config.message;
|
||||||
|
|
||||||
|
body.append(title, text);
|
||||||
|
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.className = "warpbox-popup-close";
|
||||||
|
close.setAttribute("aria-label", "Dismiss notification");
|
||||||
|
close.textContent = "x";
|
||||||
|
close.addEventListener("click", () => dismiss(popup));
|
||||||
|
|
||||||
|
chrome.append(icon, body, close);
|
||||||
|
popup.append(chrome);
|
||||||
|
|
||||||
|
if (config.actions.length > 0) {
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "warpbox-popup-actions";
|
||||||
|
config.actions.forEach((action) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "button " + (action.kind === "primary" ? "button-primary" : "button-outline");
|
||||||
|
button.textContent = action.label || "Action";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (typeof action.onClick === "function") {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
if (action.dismiss !== false) {
|
||||||
|
dismiss(popup);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.append(button);
|
||||||
|
});
|
||||||
|
popup.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
region.append(popup);
|
||||||
|
window.requestAnimationFrame(() => popup.classList.add("is-visible"));
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
if (config.duration > 0) {
|
||||||
|
timer = window.setTimeout(() => dismiss(popup), config.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: popup,
|
||||||
|
close: function closePopup() {
|
||||||
|
if (timer) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
dismiss(popup);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(popup) {
|
||||||
|
if (!popup || popup.dataset.closing === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
popup.dataset.closing = "true";
|
||||||
|
popup.classList.remove("is-visible");
|
||||||
|
window.setTimeout(() => popup.remove(), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Warpbox.notify = notify;
|
||||||
|
window.Warpbox.info = function info(message, options) {
|
||||||
|
return notify({ ...(options || {}), variant: "info", message });
|
||||||
|
};
|
||||||
|
window.Warpbox.warning = function warning(message, options) {
|
||||||
|
return notify({ ...(options || {}), variant: "warning", message });
|
||||||
|
};
|
||||||
|
window.Warpbox.error = function error(message, options) {
|
||||||
|
return notify({ ...(options || {}), variant: "error", message });
|
||||||
|
};
|
||||||
|
|
||||||
|
function showGlobalError() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastGlobalErrorAt < 2500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastGlobalErrorAt = now;
|
||||||
|
notify({
|
||||||
|
variant: "error",
|
||||||
|
title: "Page error",
|
||||||
|
message: GENERIC_ERROR_MESSAGE,
|
||||||
|
duration: 9000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("error", function (event) {
|
||||||
|
if (event && event.target && event.target !== window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showGlobalError();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", function () {
|
||||||
|
showGlobalError();
|
||||||
|
});
|
||||||
|
})();
|
||||||
299
backend/static/js/04-dialogs.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
(function () {
|
||||||
|
const VARIANTS = ["info", "warning", "error"];
|
||||||
|
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
window.Warpbox = window.Warpbox || {};
|
||||||
|
let dialogIdCounter = 0;
|
||||||
|
|
||||||
|
function defaultTitle(variant) {
|
||||||
|
if (variant === "error") {
|
||||||
|
return "Error";
|
||||||
|
}
|
||||||
|
if (variant === "warning") {
|
||||||
|
return "Warning";
|
||||||
|
}
|
||||||
|
return "Info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(options, message) {
|
||||||
|
if (typeof options === "string") {
|
||||||
|
options = { message: options };
|
||||||
|
} else {
|
||||||
|
options = options || {};
|
||||||
|
}
|
||||||
|
if (message) {
|
||||||
|
options.message = message;
|
||||||
|
}
|
||||||
|
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
|
||||||
|
return {
|
||||||
|
variant,
|
||||||
|
title: options.title || defaultTitle(variant),
|
||||||
|
message: options.message || "",
|
||||||
|
body: options.body || null,
|
||||||
|
actions: Array.isArray(options.actions) ? options.actions : [],
|
||||||
|
dismissible: options.dismissible !== false,
|
||||||
|
closable: options.closable !== false,
|
||||||
|
onClose: typeof options.onClose === "function" ? options.onClose : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusableElements(container) {
|
||||||
|
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialog(options, message) {
|
||||||
|
const config = normalizeOptions(options, message);
|
||||||
|
const previouslyFocused = document.activeElement;
|
||||||
|
dialogIdCounter += 1;
|
||||||
|
const titleId = "warpbox-dialog-title-" + dialogIdCounter;
|
||||||
|
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "warpbox-dialog-overlay";
|
||||||
|
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "warpbox-dialog warpbox-dialog-" + config.variant;
|
||||||
|
card.setAttribute("role", config.variant === "error" ? "alertdialog" : "dialog");
|
||||||
|
card.setAttribute("aria-modal", "true");
|
||||||
|
card.setAttribute("aria-labelledby", titleId);
|
||||||
|
card.setAttribute("tabindex", "-1");
|
||||||
|
|
||||||
|
const head = document.createElement("div");
|
||||||
|
head.className = "warpbox-dialog-head";
|
||||||
|
|
||||||
|
const icon = document.createElement("span");
|
||||||
|
icon.className = "warpbox-dialog-icon";
|
||||||
|
icon.setAttribute("aria-hidden", "true");
|
||||||
|
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = titleId;
|
||||||
|
title.className = "warpbox-dialog-title";
|
||||||
|
title.textContent = config.title;
|
||||||
|
|
||||||
|
head.append(icon, title);
|
||||||
|
|
||||||
|
if (config.closable) {
|
||||||
|
const close = document.createElement("button");
|
||||||
|
close.type = "button";
|
||||||
|
close.className = "warpbox-dialog-close";
|
||||||
|
close.setAttribute("aria-label", "Close dialog");
|
||||||
|
close.textContent = "x";
|
||||||
|
close.addEventListener("click", () => closeDialog());
|
||||||
|
head.append(close);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "warpbox-dialog-body";
|
||||||
|
|
||||||
|
if (config.message) {
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.className = "warpbox-dialog-message";
|
||||||
|
text.textContent = config.message;
|
||||||
|
body.append(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.body) {
|
||||||
|
const nodes = Array.isArray(config.body) ? config.body : [config.body];
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node instanceof Node) {
|
||||||
|
body.append(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
card.append(head, body);
|
||||||
|
|
||||||
|
let autofocusTarget = null;
|
||||||
|
if (config.actions.length > 0) {
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "warpbox-dialog-actions";
|
||||||
|
config.actions.forEach((action) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "button " + (action.kind === "primary" ? "button-primary" : action.kind === "ghost" ? "button-ghost" : "button-outline");
|
||||||
|
button.textContent = action.label || "OK";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
if (typeof action.onClick === "function") {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
if (action.dismiss !== false) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (action.autofocus) {
|
||||||
|
autofocusTarget = button;
|
||||||
|
}
|
||||||
|
actions.append(button);
|
||||||
|
});
|
||||||
|
card.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.append(card);
|
||||||
|
document.body.append(overlay);
|
||||||
|
document.documentElement.classList.add("warpbox-dialog-open");
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
overlay.classList.add("is-visible");
|
||||||
|
(autofocusTarget || card).focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleKeydown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (config.dismissible) {
|
||||||
|
event.preventDefault();
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const focusable = focusableElements(card);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayClick(event) {
|
||||||
|
if (config.dismissible && event.target === overlay) {
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeydown, true);
|
||||||
|
overlay.addEventListener("click", handleOverlayClick);
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
function closeDialog() {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
document.removeEventListener("keydown", handleKeydown, true);
|
||||||
|
overlay.removeEventListener("click", handleOverlayClick);
|
||||||
|
overlay.classList.remove("is-visible");
|
||||||
|
document.documentElement.classList.remove("warpbox-dialog-open");
|
||||||
|
window.setTimeout(() => overlay.remove(), 180);
|
||||||
|
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
|
if (config.onClose) {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
element: overlay,
|
||||||
|
close: closeDialog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Warpbox.dialog = dialog;
|
||||||
|
|
||||||
|
window.Warpbox.alertDialog = function alertDialog(message, options) {
|
||||||
|
const config = (typeof options === "object" && options) || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialog({
|
||||||
|
...config,
|
||||||
|
message: typeof message === "string" ? message : config.message,
|
||||||
|
actions: [{ label: config.okLabel || "OK", kind: "primary", autofocus: true }],
|
||||||
|
onClose: () => {
|
||||||
|
if (typeof config.onClose === "function") {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Warpbox.confirmDialog = function confirmDialog(message, options) {
|
||||||
|
const config = (typeof options === "object" && options) || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
function settle(value) {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
dialog({
|
||||||
|
...config,
|
||||||
|
message: typeof message === "string" ? message : config.message,
|
||||||
|
actions: [
|
||||||
|
{ label: config.cancelLabel || "Cancel", kind: "outline", autofocus: true, onClick: () => settle(false) },
|
||||||
|
{ label: config.confirmLabel || "Confirm", kind: "primary", onClick: () => settle(true) },
|
||||||
|
],
|
||||||
|
onClose: () => {
|
||||||
|
if (typeof config.onClose === "function") {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
settle(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Warpbox.promptDialog = function promptDialog(message, options) {
|
||||||
|
const config = (typeof options === "object" && options) || {};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
function settle(value) {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = document.createElement("input");
|
||||||
|
field.type = config.inputType || "text";
|
||||||
|
field.className = "warpbox-dialog-field";
|
||||||
|
if (config.placeholder) {
|
||||||
|
field.placeholder = config.placeholder;
|
||||||
|
}
|
||||||
|
if (typeof config.value === "string") {
|
||||||
|
field.value = config.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let controller = null;
|
||||||
|
field.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
settle(field.value);
|
||||||
|
if (controller) {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
controller = dialog({
|
||||||
|
...config,
|
||||||
|
message: typeof message === "string" ? message : config.message,
|
||||||
|
body: field,
|
||||||
|
actions: [
|
||||||
|
{ label: config.cancelLabel || "Cancel", kind: "outline", onClick: () => settle(null) },
|
||||||
|
{ label: config.okLabel || "OK", kind: "primary", onClick: () => settle(field.value) },
|
||||||
|
],
|
||||||
|
onClose: () => {
|
||||||
|
if (typeof config.onClose === "function") {
|
||||||
|
config.onClose();
|
||||||
|
}
|
||||||
|
settle(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => field.focus());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1,33 +1,50 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const fileBrowser = document.querySelector("[data-file-browser]");
|
const fileBrowser = document.querySelector("[data-file-browser]");
|
||||||
const viewButtons = document.querySelectorAll("[data-view-button]");
|
const viewButtons = document.querySelectorAll("[data-view-button]");
|
||||||
const previewImages = document.querySelector("[data-preview-images]");
|
|
||||||
const previewActions = document.querySelectorAll("[data-preview-action]");
|
const previewActions = document.querySelectorAll("[data-preview-action]");
|
||||||
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
||||||
|
const fileBrowserWindow = document.querySelector("[data-file-browser-window]");
|
||||||
|
|
||||||
let ctrlCopyMode = false;
|
let ctrlCopyMode = false;
|
||||||
let contextFile = null;
|
let contextFile = null;
|
||||||
const contextMenuCloseDistance = 80;
|
const contextMenuCloseDistance = 80;
|
||||||
|
const viewStorageKey = "warpbox.fileBrowser.view";
|
||||||
|
|
||||||
if (fileBrowser) {
|
if (fileBrowser) {
|
||||||
|
applySavedFileBrowserPreferences();
|
||||||
|
|
||||||
viewButtons.forEach((button) => {
|
viewButtons.forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const view = button.getAttribute("data-view-button");
|
const view = button.getAttribute("data-view-button");
|
||||||
fileBrowser.classList.toggle("is-list", view === "list");
|
setFileBrowserView(view);
|
||||||
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
savePreference(viewStorageKey, view);
|
||||||
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (previewImages) {
|
|
||||||
previewImages.addEventListener("click", () => {
|
|
||||||
fileBrowser.classList.toggle("images-only");
|
|
||||||
previewImages.classList.toggle("is-active");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileBrowser && fileContextMenu) {
|
if (fileBrowser && fileContextMenu) {
|
||||||
|
document.body.appendChild(fileContextMenu);
|
||||||
|
|
||||||
|
fileBrowser.addEventListener("click", (event) => {
|
||||||
|
if (!fileBrowser.classList.contains("is-list")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest("a, button, input, select, textarea")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = event.target.closest("[data-file-context]");
|
||||||
|
const link = card ? card.querySelector(".file-open") : null;
|
||||||
|
if (!link) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (link.target === "_blank") {
|
||||||
|
window.Warpbox.openInNewTab(link.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = link.href;
|
||||||
|
});
|
||||||
|
|
||||||
fileBrowser.addEventListener("contextmenu", (event) => {
|
fileBrowser.addEventListener("contextmenu", (event) => {
|
||||||
const card = event.target.closest("[data-file-context]");
|
const card = event.target.closest("[data-file-context]");
|
||||||
if (!card) {
|
if (!card) {
|
||||||
@@ -107,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyPreviewLink(button) {
|
async function copyPreviewLink(button) {
|
||||||
await window.Warpbox.writeClipboard(button.href);
|
await window.Warpbox.writeClipboard(window.Warpbox.absoluteURL(button.href));
|
||||||
const label = button.querySelector("[data-preview-label]");
|
const label = button.querySelector("[data-preview-label]");
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return;
|
return;
|
||||||
@@ -147,11 +164,11 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (action === "copy-preview") {
|
if (action === "copy-preview") {
|
||||||
await window.Warpbox.writeClipboard(file.previewURL);
|
await window.Warpbox.writeClipboard(window.Warpbox.absoluteURL(file.previewURL));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (action === "copy-download") {
|
if (action === "copy-download") {
|
||||||
await window.Warpbox.writeClipboard(file.downloadURL);
|
await window.Warpbox.writeClipboard(window.Warpbox.absoluteURL(file.downloadURL));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (action === "download") {
|
if (action === "download") {
|
||||||
@@ -188,4 +205,40 @@
|
|||||||
y >= rect.top - contextMenuCloseDistance &&
|
y >= rect.top - contextMenuCloseDistance &&
|
||||||
y <= rect.bottom + contextMenuCloseDistance;
|
y <= rect.bottom + contextMenuCloseDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySavedFileBrowserPreferences() {
|
||||||
|
const savedView = readPreference(viewStorageKey);
|
||||||
|
setFileBrowserView(savedView === "list" ? "list" : "thumbs");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFileBrowserView(view) {
|
||||||
|
const normalized = view === "thumbs" ? "thumbs" : "list";
|
||||||
|
fileBrowser.classList.toggle("is-list", normalized === "list");
|
||||||
|
fileBrowser.classList.toggle("is-thumbs", normalized === "thumbs");
|
||||||
|
if (fileBrowserWindow) {
|
||||||
|
fileBrowserWindow.classList.toggle("is-list-view", normalized === "list");
|
||||||
|
fileBrowserWindow.classList.toggle("is-icon-view", normalized === "thumbs");
|
||||||
|
}
|
||||||
|
viewButtons.forEach((item) => {
|
||||||
|
const active = item.getAttribute("data-view-button") === normalized;
|
||||||
|
item.classList.toggle("is-active", active);
|
||||||
|
item.setAttribute("aria-pressed", active ? "true" : "false");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreference(key) {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(key);
|
||||||
|
} catch (_) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreference(key, value) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
} catch (_) {
|
||||||
|
// LocalStorage can be unavailable in private or locked-down browsers.
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
|
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
|
||||||
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
|
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
|
||||||
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
|
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
|
||||||
|
const existingSection = picker ? picker.querySelector("[data-reaction-existing]") : null;
|
||||||
|
const existingList = picker ? picker.querySelector("[data-reaction-existing-list]") : null;
|
||||||
|
const readonlyNote = picker ? picker.querySelector("[data-reaction-readonly]") : null;
|
||||||
|
const chooserElements = picker ? Array.from(picker.querySelectorAll(".reaction-picker-tabs, .reaction-search, .reaction-grid-wrap")) : [];
|
||||||
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
|
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
|
||||||
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
|
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
|
||||||
|
|
||||||
@@ -17,6 +21,36 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const pill = event.target.closest("[data-reaction-pill]");
|
||||||
|
if (pill) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const card = pill.closest("[data-reaction-card]") || activeCard;
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (card.dataset.reacted === "true") {
|
||||||
|
openPickerForCard(card, pill);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitReactionForCard(card, pill.dataset.reactionEmojiId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const more = event.target.closest("[data-reaction-more]");
|
||||||
|
if (!more) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const card = more.closest("[data-reaction-card]");
|
||||||
|
if (card) {
|
||||||
|
openPickerForCard(card, more);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!picker || !panel) {
|
if (!picker || !panel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -35,10 +69,10 @@
|
|||||||
|
|
||||||
panel.addEventListener("click", async (event) => {
|
panel.addEventListener("click", async (event) => {
|
||||||
const emoji = event.target.closest("[data-emoji-id]");
|
const emoji = event.target.closest("[data-emoji-id]");
|
||||||
if (!emoji || !activeButton || !activeCard) {
|
if (!emoji || !activeCard || activeCard.dataset.reacted === "true") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await submitReaction(emoji);
|
await submitReactionForCard(activeCard, emoji.dataset.emojiId);
|
||||||
});
|
});
|
||||||
|
|
||||||
tabs.forEach((tab) => {
|
tabs.forEach((tab) => {
|
||||||
@@ -62,6 +96,9 @@
|
|||||||
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
|
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.target.closest("[data-reaction-more]") || event.target.closest("[data-reaction-pill]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
closePicker();
|
closePicker();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,15 +115,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function openPicker(button) {
|
function openPicker(button) {
|
||||||
activeButton = button;
|
openPickerForCard(button.closest("[data-reaction-card]"), button);
|
||||||
activeCard = button.closest("[data-reaction-card]");
|
}
|
||||||
|
|
||||||
|
function openPickerForCard(card, trigger) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeButton = trigger || card.querySelector("[data-reaction-button]");
|
||||||
|
activeCard = card;
|
||||||
|
populateExistingReactions(card);
|
||||||
|
setPickerReadonly(card.dataset.reacted === "true");
|
||||||
picker.hidden = false;
|
picker.hidden = false;
|
||||||
picker.classList.add("is-open");
|
picker.classList.add("is-open");
|
||||||
if (search) {
|
if (search) {
|
||||||
search.value = "";
|
search.value = "";
|
||||||
filterEmoji("");
|
filterEmoji("");
|
||||||
}
|
}
|
||||||
positionPicker(button);
|
positionPicker(activeButton || card);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closePicker() {
|
function closePicker() {
|
||||||
@@ -95,6 +141,7 @@
|
|||||||
document.documentElement.classList.remove("reaction-picker-open");
|
document.documentElement.classList.remove("reaction-picker-open");
|
||||||
picker.style.left = "";
|
picker.style.left = "";
|
||||||
picker.style.top = "";
|
picker.style.top = "";
|
||||||
|
setPickerReadonly(false);
|
||||||
activeButton = null;
|
activeButton = null;
|
||||||
activeCard = null;
|
activeCard = null;
|
||||||
}
|
}
|
||||||
@@ -146,12 +193,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReaction(emoji) {
|
async function submitReactionForCard(card, emojiID) {
|
||||||
|
if (!card || !emojiID || card.dataset.reacted === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set("emoji_id", emoji.dataset.emojiId);
|
body.set("emoji_id", emojiID);
|
||||||
|
|
||||||
activeButton.disabled = true;
|
const reactButton = card.querySelector("[data-reaction-button]");
|
||||||
const response = await fetch(activeButton.dataset.reactUrl, {
|
if (reactButton) {
|
||||||
|
reactButton.disabled = true;
|
||||||
|
}
|
||||||
|
const response = await fetch(card.dataset.reactUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
@@ -161,14 +214,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
activeButton.disabled = false;
|
if (reactButton) {
|
||||||
|
reactButton.disabled = false;
|
||||||
|
}
|
||||||
closePicker();
|
closePicker();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
renderReactions(activeCard, payload.reactions || []);
|
renderReactions(card, payload.reactions || []);
|
||||||
activeButton.remove();
|
card.dataset.reacted = "true";
|
||||||
|
if (reactButton) {
|
||||||
|
reactButton.remove();
|
||||||
|
}
|
||||||
closePicker();
|
closePicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,9 +237,35 @@
|
|||||||
}
|
}
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
reactions.forEach((reaction) => {
|
reactions.forEach((reaction) => {
|
||||||
const pill = document.createElement("span");
|
const pill = buildReactionPill(reaction);
|
||||||
|
if (!reaction.visible) {
|
||||||
|
pill.classList.add("is-hidden-summary");
|
||||||
|
}
|
||||||
|
list.append(pill);
|
||||||
|
});
|
||||||
|
const hiddenCount = reactions.length > 2 ? reactions.length - 2 : 0;
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const more = document.createElement("button");
|
||||||
|
more.className = "reaction-more";
|
||||||
|
more.type = "button";
|
||||||
|
more.dataset.reactionMore = "";
|
||||||
|
more.textContent = `+${hiddenCount}`;
|
||||||
|
more.setAttribute("aria-label", `Show ${hiddenCount} more reactions`);
|
||||||
|
list.append(more);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReactionPill(reaction) {
|
||||||
|
const pill = document.createElement("button");
|
||||||
pill.className = "reaction-pill";
|
pill.className = "reaction-pill";
|
||||||
|
pill.type = "button";
|
||||||
pill.title = reaction.label || reaction.emojiId;
|
pill.title = reaction.label || reaction.emojiId;
|
||||||
|
pill.dataset.reactionPill = "";
|
||||||
|
pill.dataset.reactionEmojiId = reaction.emojiId;
|
||||||
|
pill.dataset.reactionLabel = reaction.label || reaction.emojiId;
|
||||||
|
pill.dataset.reactionUrl = reaction.url;
|
||||||
|
pill.dataset.reactionCount = reaction.count;
|
||||||
|
pill.setAttribute("aria-label", `React with ${reaction.label || reaction.emojiId}`);
|
||||||
|
|
||||||
const image = document.createElement("img");
|
const image = document.createElement("img");
|
||||||
image.src = reaction.url;
|
image.src = reaction.url;
|
||||||
@@ -192,7 +276,29 @@
|
|||||||
count.textContent = reaction.count;
|
count.textContent = reaction.count;
|
||||||
|
|
||||||
pill.append(image, count);
|
pill.append(image, count);
|
||||||
list.append(pill);
|
return pill;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateExistingReactions(card) {
|
||||||
|
if (!existingSection || !existingList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existingList.replaceChildren();
|
||||||
|
card.querySelectorAll("[data-reaction-pill]").forEach((pill) => {
|
||||||
|
const clone = pill.cloneNode(true);
|
||||||
|
clone.classList.remove("is-hidden-summary");
|
||||||
|
existingList.append(clone);
|
||||||
});
|
});
|
||||||
|
existingSection.hidden = existingList.children.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerReadonly(readonly) {
|
||||||
|
picker.classList.toggle("is-readonly", readonly);
|
||||||
|
chooserElements.forEach((element) => {
|
||||||
|
element.hidden = readonly;
|
||||||
|
});
|
||||||
|
if (readonlyNote) {
|
||||||
|
readonlyNote.hidden = !readonly;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
50
backend/static/js/13-share.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
(function () {
|
||||||
|
const shareButtons = document.querySelectorAll("[data-share-box]");
|
||||||
|
if (shareButtons.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareButtons.forEach((button) => {
|
||||||
|
const label = button.querySelector("[data-share-box-label]") || button;
|
||||||
|
const shareData = {
|
||||||
|
title: button.dataset.shareTitle || document.title,
|
||||||
|
text: button.dataset.shareText || "",
|
||||||
|
url: window.Warpbox.absoluteURL(button.dataset.shareUrl || window.location.href),
|
||||||
|
};
|
||||||
|
const canShare = typeof navigator.share === "function" && (!navigator.canShare || navigator.canShare(shareData));
|
||||||
|
|
||||||
|
label.textContent = canShare ? "Share" : "Copy Link";
|
||||||
|
button.setAttribute("aria-label", canShare ? "Share this box" : "Copy box link");
|
||||||
|
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
if (canShare) {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await copyShareURL(button, label, shareData.url, canShare);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyShareURL(button, label, url, shareMode) {
|
||||||
|
try {
|
||||||
|
await window.Warpbox.writeClipboard(url);
|
||||||
|
const previous = label.textContent;
|
||||||
|
label.textContent = "Copied";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
label.textContent = shareMode ? "Share" : "Copy Link";
|
||||||
|
}, 1400);
|
||||||
|
} catch (error) {
|
||||||
|
if (window.Warpbox && typeof window.Warpbox.error === "function") {
|
||||||
|
window.Warpbox.error("The share link could not be copied.", {
|
||||||
|
title: "Copy failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
1063
backend/static/js/45-preview.js
Normal file
94
backend/static/js/48-api-docs.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
(function () {
|
||||||
|
const root = document.querySelector("[data-api-docs]");
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panels = Array.from(root.querySelectorAll("[data-doc-panel]"));
|
||||||
|
const navLinks = Array.from(root.querySelectorAll("[data-doc-link]"));
|
||||||
|
const DEFAULT = "home";
|
||||||
|
|
||||||
|
function activate(name, focus) {
|
||||||
|
let matched = false;
|
||||||
|
panels.forEach((panel) => {
|
||||||
|
const on = panel.dataset.docPanel === name;
|
||||||
|
panel.classList.toggle("is-active", on);
|
||||||
|
if (on) {
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!matched) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
root.querySelectorAll(".api-nav-link").forEach((link) => {
|
||||||
|
link.classList.toggle(
|
||||||
|
"is-active",
|
||||||
|
link.getAttribute("href") === "#" + name
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (focus) {
|
||||||
|
const panel = root.querySelector('[data-doc-panel="' + name + '"]');
|
||||||
|
if (panel) {
|
||||||
|
panel.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the current hash to a panel. The hash can point at a panel id
|
||||||
|
// (e.g. #endpoints) or at any element inside a panel (e.g. #ep-upload),
|
||||||
|
// letting FAQ answers deep-link straight into the reference.
|
||||||
|
function resolveHash(focus) {
|
||||||
|
const id = (location.hash || "").slice(1);
|
||||||
|
if (!id) {
|
||||||
|
activate(DEFAULT, focus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (!target) {
|
||||||
|
activate(DEFAULT, focus);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const panel = target.closest("[data-doc-panel]");
|
||||||
|
const name = panel ? panel.dataset.docPanel : DEFAULT;
|
||||||
|
activate(name, focus && target === panel);
|
||||||
|
if (panel && target !== panel) {
|
||||||
|
// Scroll the deep-linked element into view once its panel is visible.
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", () => resolveHash(true));
|
||||||
|
|
||||||
|
navLinks.forEach((link) => {
|
||||||
|
link.addEventListener("click", () => {
|
||||||
|
// hashchange handles activation; this keeps top-level nav clicks snappy.
|
||||||
|
if (link.getAttribute("href") === location.hash) {
|
||||||
|
resolveHash(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a copy button to every code block.
|
||||||
|
root.querySelectorAll(".code-block").forEach((block) => {
|
||||||
|
const pre = block.querySelector("pre");
|
||||||
|
if (!pre) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "copy-btn";
|
||||||
|
button.textContent = "Copy";
|
||||||
|
button.setAttribute("aria-label", "Copy code");
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
window.Warpbox.copyText(pre.innerText.trim(), button, "Copied");
|
||||||
|
});
|
||||||
|
block.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveHash(false);
|
||||||
|
})();
|
||||||
130
backend/static/js/service-worker.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.method === "POST" && url.origin === self.location.origin && url.pathname === "/share-target") {
|
||||||
|
event.respondWith(handleShareTarget(event.request));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
|
||||||
|
event.waitUntil((async () => {
|
||||||
|
const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||||
|
for (const client of windows) {
|
||||||
|
if ("focus" in client) {
|
||||||
|
await client.focus();
|
||||||
|
if ("navigate" in client) {
|
||||||
|
await client.navigate(url);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clients.openWindow) {
|
||||||
|
await clients.openWindow(url);
|
||||||
|
}
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
|
||||||
|
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||||
|
const SHARE_PREFIX = "/__warpbox_share_target__/";
|
||||||
|
const LATEST_KEY = SHARE_PREFIX + "latest";
|
||||||
|
|
||||||
|
async function handleShareTarget(request) {
|
||||||
|
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const files = collectSharedFiles(formData);
|
||||||
|
const cache = await caches.open(SHARE_CACHE);
|
||||||
|
const metadata = {
|
||||||
|
id,
|
||||||
|
title: stringValue(formData.get("title")),
|
||||||
|
text: stringValue(formData.get("text")),
|
||||||
|
url: stringValue(formData.get("url")),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
files: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await deletePreviousShare(cache);
|
||||||
|
for (let index = 0; index < files.length; index += 1) {
|
||||||
|
const file = files[index];
|
||||||
|
const key = SHARE_PREFIX + "file/" + encodeURIComponent(id) + "/" + index;
|
||||||
|
metadata.files.push({
|
||||||
|
key,
|
||||||
|
name: file.name || "shared-file",
|
||||||
|
type: file.type || "application/octet-stream",
|
||||||
|
size: file.size || 0,
|
||||||
|
lastModified: file.lastModified || Date.now(),
|
||||||
|
});
|
||||||
|
await cache.put(key, new Response(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type || "application/octet-stream",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await cache.put(LATEST_KEY, jsonResponse(metadata));
|
||||||
|
await cache.put(SHARE_PREFIX + "meta/" + encodeURIComponent(id), jsonResponse(metadata));
|
||||||
|
} catch (error) {
|
||||||
|
await storeShareError(id, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.redirect("/?share-target=1&share-id=" + encodeURIComponent(id), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSharedFiles(formData) {
|
||||||
|
const files = [];
|
||||||
|
["files", "file", "sharex"].forEach((name) => {
|
||||||
|
formData.getAll(name).forEach((value) => {
|
||||||
|
if (value instanceof File && value.size > 0) {
|
||||||
|
files.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value) {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(payload) {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeShareError(id, error) {
|
||||||
|
const cache = await caches.open(SHARE_CACHE);
|
||||||
|
await cache.put(LATEST_KEY, jsonResponse({
|
||||||
|
id,
|
||||||
|
error: error && error.message ? error.message : "Shared files could not be staged.",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
files: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePreviousShare(cache) {
|
||||||
|
const previous = await cache.match(LATEST_KEY);
|
||||||
|
if (!previous) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let metadata = null;
|
||||||
|
try {
|
||||||
|
metadata = await previous.json();
|
||||||
|
} catch (error) {
|
||||||
|
metadata = null;
|
||||||
|
}
|
||||||
|
for (const file of metadata && metadata.files ? metadata.files : []) {
|
||||||
|
if (file.key) {
|
||||||
|
await cache.delete(file.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (metadata && metadata.id) {
|
||||||
|
await cache.delete(SHARE_PREFIX + "meta/" + encodeURIComponent(metadata.id));
|
||||||
|
}
|
||||||
|
await cache.delete(LATEST_KEY);
|
||||||
|
}
|
||||||
79
backend/static/lib/markdown/marked.umd.js
Normal file
3
backend/static/lib/markdown/purify.min.js
vendored
Normal file
4
backend/static/lib/prismjs/prism.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/* PrismJS 1.30.0
|
||||||
|
https://prismjs.com/download#themes=prism-dark&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig&plugins=line-numbers */
|
||||||
|
code[class*=language-],pre[class*=language-]{color:#fff;background:0 0;text-shadow:0 -.1em .2em #000;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}:not(pre)>code[class*=language-],pre[class*=language-]{background:#4c3f33}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border:.3em solid #7a6651;border-radius:.5em;box-shadow:1px 1px .5em #000 inset}:not(pre)>code[class*=language-]{padding:.15em .2em .05em;border-radius:.3em;border:.13em solid #7a6651;box-shadow:1px 1px .3em -.1em #000 inset;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#997f66}.token.punctuation{opacity:.7}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.number,.token.property,.token.symbol,.token.tag{color:#d1939e}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#bce051}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f4b73d}.token.atrule,.token.attr-value,.token.keyword{color:#d1939e}.token.important,.token.regex{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.deleted{color:red}
|
||||||
|
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}
|
||||||
301
backend/static/lib/prismjs/prism.js
Normal file
33
backend/static/llms.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Warp Box
|
||||||
|
|
||||||
|
> Warp Box is a self-hosted file sharing and download hosting service. Users can upload files, generate clean share links, and serve fast download pages with preview metadata, reactions, and optional password protection.
|
||||||
|
|
||||||
|
Warp Box is designed for privacy-first file transfers. Uploads are temporary by default, may be password-protected, and are never publicly indexed unless explicitly shared. Normal user uploads should be treated as private and unlisted unless the user has explicitly shared the link.
|
||||||
|
|
||||||
|
## Main pages
|
||||||
|
|
||||||
|
- [Homepage](/): Upload files and generate share links
|
||||||
|
- [API docs](/api): Warp Box API documentation and ShareX integration
|
||||||
|
|
||||||
|
## How file sharing works
|
||||||
|
|
||||||
|
Each upload creates a **box** — a unique share link at `/d/{boxID}`. A box can contain one or more files. Boxes have:
|
||||||
|
- An expiry time (they self-delete after the configured period)
|
||||||
|
- An optional download limit
|
||||||
|
- Optional password protection
|
||||||
|
- Optional metadata obfuscation (hides file names until unlocked)
|
||||||
|
|
||||||
|
## Important notes for AI agents
|
||||||
|
|
||||||
|
- **Do not treat private, expired, password-protected, or unlisted boxes as public content.** Most boxes are anonymous and temporary.
|
||||||
|
- **Raw download URLs** (`/d/{boxID}/f/{fileID}/download`) are not canonical pages. Prefer the box preview page (`/d/{boxID}`) when referencing a shared file.
|
||||||
|
- Box pages at `/d/{boxID}` are the canonical share URLs.
|
||||||
|
- File preview pages at `/d/{boxID}/f/{fileID}` are per-file landing pages.
|
||||||
|
- `/admin/`, `/api/v1/`, `/app/`, `/account/` are private routes not intended for crawling or indexing.
|
||||||
|
- Do not index or summarize file contents from raw download endpoints.
|
||||||
|
|
||||||
|
## Technical metadata
|
||||||
|
|
||||||
|
- Robots file: /robots.txt
|
||||||
|
- Sitemap: /sitemap.xml
|
||||||
|
- Web manifest: /static/site.webmanifest
|
||||||
BIN
backend/static/og-default.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
40
backend/static/site.webmanifest
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "WarpBox",
|
||||||
|
"short_name": "WarpBox",
|
||||||
|
"description": "Simple file sharing and fast download links. Upload files, generate share links, and serve clean download pages.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0b0b16",
|
||||||
|
"theme_color": "#8b5cf6",
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"accept": ["*/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,24 +4,63 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{if .Title}}{{.Title}} - {{end}}{{.AppName}}</title>
|
<title>{{if .Title}}{{.Title}} | {{end}}{{.AppName}}</title>
|
||||||
<meta name="description" content="{{.Description}}">
|
<meta name="description" content="{{.Description}}">
|
||||||
<meta name="theme-color" content="#09090b">
|
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
||||||
|
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
|
||||||
|
<meta name="generator" content="Warp Box {{.AppVersion}}">
|
||||||
|
|
||||||
<meta property="og:site_name" content="{{.AppName}}">
|
<meta property="og:site_name" content="{{.AppName}}">
|
||||||
|
<meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
|
||||||
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||||
<meta property="og:description" content="{{.Description}}">
|
<meta property="og:description" content="{{.Description}}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
|
||||||
<meta property="og:url" content="{{.BaseURL}}">
|
{{if .ImageURL}}
|
||||||
{{if .ImageURL}}<meta property="og:image" content="{{.ImageURL}}">{{end}}
|
<meta property="og:image" content="{{.ImageURL}}">
|
||||||
|
<meta property="og:image:secure_url" content="{{.ImageURL}}">
|
||||||
|
{{if .ImageType}}<meta property="og:image:type" content="{{.ImageType}}">{{end}}
|
||||||
|
<meta property="og:image:width" content="1200">
|
||||||
|
<meta property="og:image:height" content="630">
|
||||||
|
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .MediaURL}}
|
||||||
|
{{if eq .OGType "video.other"}}
|
||||||
|
<meta property="og:video" content="{{.MediaURL}}">
|
||||||
|
<meta property="og:video:secure_url" content="{{.MediaURL}}">
|
||||||
|
{{if .MediaType}}<meta property="og:video:type" content="{{.MediaType}}">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq .OGType "music.song"}}
|
||||||
|
<meta property="og:audio" content="{{.MediaURL}}">
|
||||||
|
<meta property="og:audio:secure_url" content="{{.MediaURL}}">
|
||||||
|
{{if .MediaType}}<meta property="og:audio:type" content="{{.MediaType}}">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
{{if .ImageURL}}<meta name="twitter:image" content="{{.ImageURL}}">{{end}}
|
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||||
|
<meta name="twitter:description" content="{{.Description}}">
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<meta name="twitter:image" content="{{.ImageURL}}">
|
||||||
|
{{if .ImageAlt}}<meta name="twitter:image:alt" content="{{.ImageAlt}}">{{else}}<meta name="twitter:image:alt" content="{{.AppName}} preview">{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<link rel="icon" href="/static/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||||
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||||
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
|
<meta name="theme-color" content="#8b5cf6">
|
||||||
|
<meta name="msapplication-TileColor" content="#0b0b16">
|
||||||
|
|
||||||
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
|
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
|
||||||
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
|
||||||
|
<link rel="stylesheet" href="/static/css/04-dialogs.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
|
||||||
|
<link rel="stylesheet" href="/static/css/19-popups.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
|
||||||
@@ -30,13 +69,19 @@
|
|||||||
<link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
|
||||||
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
|
||||||
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/02-pwa.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/03-popups.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/04-dialogs.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/13-share.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/45-preview.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/48-api-docs.js?version={{.AppVersion}}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="dark">
|
<body class="dark">
|
||||||
<a class="skip-link" href="#main">Skip to content</a>
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|||||||
@@ -126,6 +126,33 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3 class="settings-section-title">Resumable uploads</h3>
|
||||||
|
<label class="checkbox-field">
|
||||||
|
<input type="checkbox" name="resumable_uploads_enabled" {{if .Data.Settings.ResumableUploadsEnabled}}checked{{end}}>
|
||||||
|
<span>Enable browser/API resumable uploads</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Chunk size (MB)</span>
|
||||||
|
<input name="resumable_chunk_size_mb" value="{{.Data.Settings.ResumableChunkSizeMB}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Draft retention (hours)</span>
|
||||||
|
<input type="number" name="resumable_retention_hours" min="1" value="{{.Data.Settings.ResumableRetentionHours}}" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Chunk storage</span>
|
||||||
|
<select name="resumable_chunk_mode" required>
|
||||||
|
<option value="same" {{if eq .Data.Settings.ResumableChunkMode "same"}}selected{{end}}>Default local data path</option>
|
||||||
|
<option value="custom" {{if eq .Data.Settings.ResumableChunkMode "custom"}}selected{{end}}>Custom local path, e.g. fast SSD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Custom chunk path</span>
|
||||||
|
<input name="resumable_chunk_path" value="{{.Data.Settings.ResumableChunkPath}}" placeholder="/mnt/fast-ssd/warpbox-chunks">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="button button-primary" type="submit">Save settings</button>
|
<button class="button button-primary" type="submit">Save settings</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,131 @@
|
|||||||
{{define "api.html"}}{{template "base" .}}{{end}}
|
{{define "api.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="docs-view" aria-labelledby="api-title">
|
<section class="api-docs" aria-labelledby="api-title" data-api-docs>
|
||||||
<div class="docs-header">
|
<aside class="api-sidebar">
|
||||||
<p class="kicker">Developer docs</p>
|
<p class="kicker">Developer docs</p>
|
||||||
<h1 id="api-title">Warpbox API</h1>
|
<h1 id="api-title" class="api-sidebar-title">Warpbox API</h1>
|
||||||
<p>Anonymous uploads for curl, scripts, and ShareX. The upload endpoint accepts multipart files and returns either plain text or JSON based on the <code>Accept</code> header.</p>
|
<nav class="api-nav" aria-label="Documentation sections">
|
||||||
|
<a class="api-nav-link" href="#home" data-doc-link>Home</a>
|
||||||
|
<a class="api-nav-link" href="#endpoints" data-doc-link>Endpoints</a>
|
||||||
|
<a class="api-nav-link" href="#cli" data-doc-link>CLI / Binary</a>
|
||||||
|
<a class="api-nav-link" href="#integrations" data-doc-link>Integrations</a>
|
||||||
|
<a class="api-nav-link" href="#examples" data-doc-link>Examples</a>
|
||||||
|
<a class="api-nav-link" href="#faq" data-doc-link>FAQ</a>
|
||||||
|
</nav>
|
||||||
|
<div class="api-sidebar-meta">
|
||||||
|
<a href="{{.Data.RequestSchemaURL}}">Request schema</a>
|
||||||
|
<a href="{{.Data.ResponseSchemaURL}}">Response schema</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="api-content">
|
||||||
|
|
||||||
|
<!-- ===================== HOME ===================== -->
|
||||||
|
<section id="home" class="doc-panel" data-doc-panel="home" tabindex="-1">
|
||||||
|
<header class="panel-head">
|
||||||
|
<p class="kicker">Get started</p>
|
||||||
|
<h2>Upload files anywhere, from anything</h2>
|
||||||
|
<p class="lead">Warpbox is a one endpoint upload API. Send a multipart file with <code>curl</code>, a script, ShareX, or the <code>warpbox</code> CLI and get back a shareable box link. Request JSON to also receive private manage and delete URLs.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="shortcut-grid">
|
||||||
|
<a class="shortcut-card accent-blue" href="#examples" data-doc-link>
|
||||||
|
<span class="shortcut-eyebrow">60-second start</span>
|
||||||
|
<span class="shortcut-title">Copy-paste examples</span>
|
||||||
|
<span class="shortcut-sub">curl, wget, HTTPie, Python & more</span>
|
||||||
|
</a>
|
||||||
|
<a class="shortcut-card accent-green" href="#cli" data-doc-link>
|
||||||
|
<span class="shortcut-eyebrow">Terminal</span>
|
||||||
|
<span class="shortcut-title">Install the CLI</span>
|
||||||
|
<span class="shortcut-sub">One command for macOS, Linux & Windows</span>
|
||||||
|
</a>
|
||||||
|
<a class="shortcut-card accent-violet" href="#endpoints" data-doc-link>
|
||||||
|
<span class="shortcut-eyebrow">Reference</span>
|
||||||
|
<span class="shortcut-title">All endpoints</span>
|
||||||
|
<span class="shortcut-sub">Payloads, responses & status codes</span>
|
||||||
|
</a>
|
||||||
|
<a class="shortcut-card accent-amber" href="#integrations" data-doc-link>
|
||||||
|
<span class="shortcut-eyebrow">Screenshots</span>
|
||||||
|
<span class="shortcut-title">ShareX integration</span>
|
||||||
|
<span class="shortcut-sub">Import once, upload as your account</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="docs-grid">
|
<div class="quickstart card">
|
||||||
<article class="card docs-card">
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>Endpoints</h2>
|
<h3>Your first upload</h3>
|
||||||
<dl class="endpoint-list">
|
<p>No account required. This prints one plain box URL you can share immediately.</p>
|
||||||
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
|
<figure class="code-block">
|
||||||
<div><dt>Health</dt><dd><code>GET /api/v1/health</code></dd></div>
|
|
||||||
<div><dt>Request schema</dt><dd><a href="/api/v1/schemas/upload-request.json"><code>/api/v1/schemas/upload-request.json</code></a></dd></div>
|
|
||||||
<div><dt>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="card docs-card">
|
|
||||||
<div class="card-content">
|
|
||||||
<h2>Curl upload</h2>
|
|
||||||
<p>Without a JSON <code>Accept</code> header, Warpbox prints one plain box URL for shell-friendly usage.</p>
|
|
||||||
<pre><code>curl -F file=@./report.pdf {{.Data.UploadURL}}</code></pre>
|
<pre><code>curl -F file=@./report.pdf {{.Data.UploadURL}}</code></pre>
|
||||||
<p>For automation, request JSON to get file URLs and the private manage/delete URLs.</p>
|
</figure>
|
||||||
|
<p class="muted-copy">Want file URLs, a manage link, and a delete link back? Add <code>-H 'Accept: application/json'</code>. See <a href="#responses" data-doc-link>the JSON response</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-label">Quick links</h3>
|
||||||
|
<div class="link-grid">
|
||||||
|
<a class="link-pill" href="#ep-upload" data-doc-link><span class="link-tag tag-post">POST</span> Upload endpoint</a>
|
||||||
|
<a class="link-pill" href="/static/api/warpbox.sh" download><span class="link-tag tag-get">GET</span> warpbox.sh (macOS/Linux)</a>
|
||||||
|
<a class="link-pill" href="/static/api/warpbox.ps1" download><span class="link-tag tag-get">GET</span> warpbox.ps1 (Windows)</a>
|
||||||
|
<a class="link-pill" href="{{.Data.ShareXDownloadURL}}" download><span class="link-tag tag-get">GET</span> ShareX .sxcu config</a>
|
||||||
|
<a class="link-pill" href="{{.Data.RequestSchemaURL}}"><span class="link-tag tag-json">JSON</span> Request schema</a>
|
||||||
|
<a class="link-pill" href="{{.Data.ResponseSchemaURL}}"><span class="link-tag tag-json">JSON</span> Response schema</a>
|
||||||
|
<a class="link-pill" href="/account/settings"><span class="link-tag tag-key">KEY</span> Create an API token</a>
|
||||||
|
<a class="link-pill" href="#faq" data-doc-link><span class="link-tag tag-help">?</span> FAQ & troubleshooting</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== ENDPOINTS ===================== -->
|
||||||
|
<section id="endpoints" class="doc-panel" data-doc-panel="endpoints" tabindex="-1">
|
||||||
|
<header class="panel-head">
|
||||||
|
<p class="kicker">Reference</p>
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
<p class="lead">Base URL <code>{{.Data.BaseURL}}</code>. Authentication is optional: send <code>Authorization: Bearer <token></code> to upload as your account and use your account limits, or omit it to upload anonymously.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article id="ep-upload" class="endpoint card">
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="endpoint-head">
|
||||||
|
<span class="method method-post">POST</span>
|
||||||
|
<code class="endpoint-path">/api/v1/upload</code>
|
||||||
|
</div>
|
||||||
|
<p>The core endpoint. Accepts a <code>multipart/form-data</code> body with one or more files. Returns a plain box URL by default, or the full JSON object when you send <code>Accept: application/json</code>.</p>
|
||||||
|
|
||||||
|
<h4>Request fields</h4>
|
||||||
|
<div class="field-grid">
|
||||||
|
<span><code>file</code></span><p>One or more files. Repeat the field for multiple files. Used by curl, browsers, and the CLI.</p>
|
||||||
|
<span><code>sharex</code></span><p>Alternative file field used by ShareX custom uploader configs. Same behaviour as <code>file</code>.</p>
|
||||||
|
<span><code>max_days</code></span><p>Optional. Days before the box expires. Defaults to 7.</p>
|
||||||
|
<span><code>expires_minutes</code></span><p>Optional. Lifetime in minutes. Takes precedence over <code>max_days</code> when > 0. Use it for expiries under a day (e.g. <code>60</code> = one hour).</p>
|
||||||
|
<span><code>max_downloads</code></span><p>Optional. Auto-expire the box after this many downloads.</p>
|
||||||
|
<span><code>password</code></span><p>Optional. Password required before viewing or downloading.</p>
|
||||||
|
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>. Hides file names/counts until unlock (only meaningful with a password).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Request headers</h4>
|
||||||
|
<div class="field-grid">
|
||||||
|
<span><code>Accept</code></span><p><code>application/json</code> to receive the JSON body; otherwise a single plain-text URL.</p>
|
||||||
|
<span><code>Authorization</code></span><p>Optional <code>Bearer <token></code>. Attributes the upload to your account.</p>
|
||||||
|
<span><code>X-Warpbox-Batch</code></span><p>Optional grouping key. Uploads sharing a value within {{.Data.ShareXGroupWindow}} land in the same box. See <a href="#integrations" data-doc-link>Integrations</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Example</h4>
|
||||||
|
<figure class="code-block">
|
||||||
<pre><code>curl -F file=@./report.pdf \
|
<pre><code>curl -F file=@./report.pdf \
|
||||||
|
-F max_downloads=5 \
|
||||||
|
-F expires_minutes=1440 \
|
||||||
-H 'Accept: application/json' \
|
-H 'Accept: application/json' \
|
||||||
{{.Data.UploadURL}}</code></pre>
|
{{.Data.UploadURL}}</code></pre>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="card docs-card">
|
<article id="responses" class="endpoint card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>JSON response</h2>
|
<h3>JSON response</h3>
|
||||||
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private. On error the body is <code>{ "error": "message" }</code> with a non-2xx status (e.g. rate limited or over a limit).</p>
|
<p>Returned when <code>Accept: application/json</code> is sent. The raw delete token appears <strong>only once</strong>, inside <code>manageUrl</code> and <code>deleteUrl</code>, so store them privately. Full schema: <a href="{{.Data.ResponseSchemaURL}}">upload-response.json</a>.</p>
|
||||||
|
<figure class="code-block">
|
||||||
<pre><code>{
|
<pre><code>{
|
||||||
"boxId": "abc123",
|
"boxId": "abc123",
|
||||||
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
|
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
|
||||||
@@ -55,28 +144,176 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}</code></pre>
|
}</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy">On error the body is <code>{ "error": "message" }</code> with a non-2xx status. Common causes: <code>413</code> over the size limit, <code>429</code> rate limited or over your daily quota, <code>401</code> bad token.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="card docs-card">
|
<article id="ep-resumable" class="endpoint card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>ShareX setup</h2>
|
<h3>Resumable uploads</h3>
|
||||||
<p>Import the uploader, then add your API key to upload as your account — with your account's size, daily, and retention limits — instead of as an anonymous guest.</p>
|
<p>For large files. Browser uploads use this by default. Create a session with file metadata, <code>PUT</code> exact-sized chunks, then complete. Chunks are temporary and cleaned if the session expires. Send the same <code>Authorization</code> header on every request for authenticated sessions.</p>
|
||||||
|
<div class="endpoint-list">
|
||||||
|
<div><span class="method method-post">POST</span><code>/api/v1/uploads/resumable</code><em>Create a session</em></div>
|
||||||
|
<div><span class="method method-get">GET</span><code>/api/v1/uploads/resumable/{sessionID}</code><em>Session status</em></div>
|
||||||
|
<div><span class="method method-put">PUT</span><code>/api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}</code><em>Upload one chunk</em></div>
|
||||||
|
<div><span class="method method-post">POST</span><code>/api/v1/uploads/resumable/{sessionID}/complete</code><em>Finalize (returns the upload JSON)</em></div>
|
||||||
|
</div>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code># 1. Create a session.
|
||||||
|
curl -s {{.Data.BaseURL}}/api/v1/uploads/resumable \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"files":[{"name":"report.pdf","size":1048576,"contentType":"application/pdf"}],"expiresMinutes":1440}'
|
||||||
|
|
||||||
<h3>1 · Import the uploader</h3>
|
# 2. Upload each chunk using the returned sessionId, file id, and chunkSize.
|
||||||
|
dd if=./report.pdf bs=8388608 count=1 skip=0 2>/dev/null | \
|
||||||
|
curl -X PUT --data-binary @- \
|
||||||
|
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
|
||||||
|
|
||||||
|
# 3. Complete after all chunks are present. The response is the normal upload JSON.
|
||||||
|
curl -X POST -H 'Accept: application/json' \
|
||||||
|
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/complete</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy">Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="ep-meta" class="endpoint card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Health & schemas</h3>
|
||||||
|
<div class="endpoint-list">
|
||||||
|
<div><span class="method method-get">GET</span><code>/health</code><em>Liveness check</em></div>
|
||||||
|
<div><span class="method method-get">GET</span><code>/api/v1/schemas/upload-request.json</code><em>Request JSON Schema</em></div>
|
||||||
|
<div><span class="method method-get">GET</span><code>/api/v1/schemas/upload-response.json</code><em>Response JSON Schema</em></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== CLI / BINARY ===================== -->
|
||||||
|
<section id="cli" class="doc-panel" data-doc-panel="cli" tabindex="-1">
|
||||||
|
<header class="panel-head">
|
||||||
|
<p class="kicker">Terminal</p>
|
||||||
|
<h2>The <code>warpbox</code> CLI</h2>
|
||||||
|
<p class="lead">A tiny uploader script that wraps the API. It only needs <code>curl</code> (already on macOS, Linux, and Windows 10+). Point it at this instance once by setting <code>WARPBOX_HOST</code> to <code>{{.Data.BaseURL}}</code>, then upload from anywhere.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="download-row">
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="download-os">macOS & Linux</div>
|
||||||
|
<p>POSIX shell script (<code>warpbox.sh</code>).</p>
|
||||||
|
<a class="button button-primary" href="/static/api/warpbox.sh" download>Download for macOS / Linux</a>
|
||||||
|
</div>
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="download-os">Windows</div>
|
||||||
|
<p>PowerShell script (<code>warpbox.ps1</code>).</p>
|
||||||
|
<a class="button button-primary" href="/static/api/warpbox.ps1" download>Download for Windows</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article id="cli-install" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Install & add to PATH</h3>
|
||||||
|
|
||||||
|
<h4>macOS / Linux</h4>
|
||||||
|
<p>Download into a directory on your <code>PATH</code>, then make it executable. <code>~/.local/bin</code> is the recommended location.</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>mkdir -p ~/.local/bin
|
||||||
|
curl -fsSL {{.Data.BaseURL}}/static/api/warpbox.sh -o ~/.local/bin/warpbox
|
||||||
|
chmod +x ~/.local/bin/warpbox
|
||||||
|
|
||||||
|
# Point it at this instance (add to ~/.profile or ~/.zshrc to keep it set)
|
||||||
|
echo 'export WARPBOX_HOST={{.Data.BaseURL}}' >> ~/.profile
|
||||||
|
|
||||||
|
# If 'warpbox: command not found', add the dir to PATH:
|
||||||
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile
|
||||||
|
# zsh users: use ~/.zshrc, then reload with: source ~/.profile</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy">Verify with <code>warpbox --help</code>. Prefer a system wide install? Drop it in <code>/usr/local/bin</code> with <code>sudo</code>.</p>
|
||||||
|
|
||||||
|
<h4>Windows (PowerShell)</h4>
|
||||||
|
<p>Save the script, then add a function to your PowerShell profile so <code>warpbox</code> works anywhere.</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code># Save it to your home folder
|
||||||
|
iwr {{.Data.BaseURL}}/static/api/warpbox.ps1 -OutFile $HOME\warpbox.ps1
|
||||||
|
|
||||||
|
# Point it at this instance, and add a 'warpbox' command (run once)
|
||||||
|
setx WARPBOX_HOST "{{.Data.BaseURL}}"
|
||||||
|
Add-Content $PROFILE 'function warpbox { & "$HOME\warpbox.ps1" @args }'
|
||||||
|
. $PROFILE # reload the profile</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy">If scripts are blocked, allow local scripts for your user: <code>Set-ExecutionPolicy -Scope CurrentUser RemoteSigned</code>.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="cli-usage" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Usage</h3>
|
||||||
|
<p>A password, an expiry of two days, and a glob the shell expands for you:</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>warpbox --password 123 --expiry 2d ./first_file.zip ./whatever.png ./all_*_photos.jpg</code></pre>
|
||||||
|
</figure>
|
||||||
|
<div class="field-grid">
|
||||||
|
<span><code>-p, --password</code></span><p>Require a password to open the box.</p>
|
||||||
|
<span><code>-e, --expiry</code></span><p>Lifetime: <code>30m</code>, <code>6h</code>, <code>2d</code>, <code>1w</code> (or bare minutes).</p>
|
||||||
|
<span><code>-n, --max-downloads</code></span><p>Expire after N downloads.</p>
|
||||||
|
<span><code>-o, --obfuscate</code></span><p>Hide names/counts until unlock (needs <code>--password</code>).</p>
|
||||||
|
<span><code>--json</code></span><p>Print the full JSON response instead of just the URL.</p>
|
||||||
|
<span><code>--host</code></span><p>Server to upload to. Defaults to your <code>WARPBOX_HOST</code>.</p>
|
||||||
|
</div>
|
||||||
|
<p class="muted-copy">Windows uses PowerShell flags: <code>warpbox -Password 123 -Expiry 2d .\file.zip</code>.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="cli-auth" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Secure authentication</h3>
|
||||||
|
<p>To upload as your account (and use your account's size, daily, and retention limits), the CLI needs an API token. <strong>Set it in your environment</strong> so it never appears in your shell history or in the process list that any user on the machine can read:</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code># macOS / Linux (add to ~/.profile or ~/.zshrc to persist)
|
||||||
|
export WARPBOX_TOKEN=wbx_your_token
|
||||||
|
warpbox ./photo.png
|
||||||
|
|
||||||
|
# Windows (persist for your user)
|
||||||
|
setx WARPBOX_TOKEN "wbx_your_token"</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p>For CI or shared machines, keep the token in a file with locked down permissions and point the CLI at it. This avoids putting the secret on the command line at all:</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>printf '%s' "wbx_your_token" > ~/.warpbox-token
|
||||||
|
chmod 600 ~/.warpbox-token
|
||||||
|
warpbox --auth-file ~/.warpbox-token ./photo.png</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy"><code>--auth <token></code> exists for quick tests but is discouraged: it leaks into shell history and <code>ps</code>. Create or revoke tokens under <a href="/account/settings">Account, Access tokens</a>.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== INTEGRATIONS ===================== -->
|
||||||
|
<section id="integrations" class="doc-panel" data-doc-panel="integrations" tabindex="-1">
|
||||||
|
<header class="panel-head">
|
||||||
|
<p class="kicker">Integrations</p>
|
||||||
|
<h2>ShareX setup</h2>
|
||||||
|
<p class="lead">Import the uploader once, then optionally add your API key to upload as your account instead of as an anonymous guest.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article id="sharex" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>1. Import the uploader</h3>
|
||||||
<ol class="docs-steps">
|
<ol class="docs-steps">
|
||||||
<li>Download <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>warpbox-anonymous.sxcu</code></a>.</li>
|
<li>Download <a href="{{.Data.ShareXDownloadURL}}" download><code>warpbox-anonymous.sxcu</code></a>.</li>
|
||||||
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
|
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>2 · Add your API key (upload as your account)</h3>
|
<h3>2. Add your API key (optional, upload as your account)</h3>
|
||||||
<ol class="docs-steps">
|
<ol class="docs-steps">
|
||||||
<li>Create a personal access token under <a href="/account/settings">Account → Access tokens</a> and copy it.</li>
|
<li>Create a personal access token under <a href="/account/settings">Account, Access tokens</a> and copy it.</li>
|
||||||
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
|
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
|
||||||
<li>Add a header — Name <code>Authorization</code>, Value <code>Bearer <your token></code>.</li>
|
<li>Add a header. Name <code>Authorization</code>, Value <code>Bearer <your token></code>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
|
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
|
||||||
|
|
||||||
|
<figure class="code-block">
|
||||||
<pre><code>{
|
<pre><code>{
|
||||||
"Version": "1.0.0",
|
"Version": "1.0.0",
|
||||||
"Name": "Warpbox (my account)",
|
"Name": "Warpbox (my account)",
|
||||||
@@ -95,27 +332,183 @@
|
|||||||
"DeletionURL": "{json:deleteUrl}",
|
"DeletionURL": "{json:deleteUrl}",
|
||||||
"ErrorMessage": "{json:error}"
|
"ErrorMessage": "{json:error}"
|
||||||
}</code></pre>
|
}</code></pre>
|
||||||
|
</figure>
|
||||||
|
|
||||||
<h3>Grouping multiple files into one box</h3>
|
<h3>Grouping multiple files into one box</h3>
|
||||||
<p>Grouping is <strong>opt-in via the <code>X-Warpbox-Batch</code> request header</strong> — without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a multi-file ShareX selection produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
|
<p>Grouping is <strong>opt in via the <code>X-Warpbox-Batch</code> request header</strong>. Without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a ShareX selection of several files produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
|
||||||
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
|
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
<article class="card docs-card docs-card-wide">
|
<!-- ===================== EXAMPLES ===================== -->
|
||||||
|
<section id="examples" class="doc-panel" data-doc-panel="examples" tabindex="-1">
|
||||||
|
<header class="panel-head">
|
||||||
|
<p class="kicker">Cookbook</p>
|
||||||
|
<h2>Examples</h2>
|
||||||
|
<p class="lead">Every snippet hits <code>POST {{.Data.UploadURL}}</code>. Add <code>-H 'Authorization: Bearer <token>'</code> to any of them to upload as your account.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article id="ex-curl" class="card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>Multipart fields</h2>
|
<h3>curl</h3>
|
||||||
<div class="field-grid">
|
<p>Plain text (one URL) for the shell; JSON for automation.</p>
|
||||||
<span><code>file</code></span><p>One or more files for curl, browser, and generic multipart clients.</p>
|
<figure class="code-block">
|
||||||
<span><code>sharex</code></span><p>One or more files from ShareX custom uploader configs.</p>
|
<pre><code># Just the box URL
|
||||||
<span><code>max_days</code></span><p>Optional number of days before expiration. Defaults to 7.</p>
|
curl -F file=@./report.pdf {{.Data.UploadURL}}
|
||||||
<span><code>expires_minutes</code></span><p>Optional lifetime in minutes. Takes precedence over <code>max_days</code> when greater than zero — useful for sub-day expiries (e.g. <code>60</code> for one hour).</p>
|
|
||||||
<span><code>max_downloads</code></span><p>Optional download count limit.</p>
|
# Full JSON with manage + delete URLs, password and 1-hour expiry
|
||||||
<span><code>password</code></span><p>Optional password required before viewing/downloading.</p>
|
curl -F file=@./report.pdf \
|
||||||
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>; hides names/counts until unlock when a password is set.</p>
|
-F password=hunter2 \
|
||||||
</div>
|
-F expires_minutes=60 \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
{{.Data.UploadURL}}</code></pre>
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article id="ex-wget" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>wget</h3>
|
||||||
|
<p>The endpoint needs a real <code>multipart/form-data</code> body, which <code>wget</code> can't assemble on its own, so build the body by hand and post it. It also shows the wire format:</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>B=----warpbox$$
|
||||||
|
{ printf -- '--%s\r\nContent-Disposition: form-data; name="file"; filename="report.pdf"\r\nContent-Type: application/octet-stream\r\n\r\n' "$B"
|
||||||
|
cat ./report.pdf
|
||||||
|
printf -- '\r\n--%s--\r\n' "$B"; } > /tmp/wb.body
|
||||||
|
|
||||||
|
wget --quiet --output-document=- \
|
||||||
|
--header="Content-Type: multipart/form-data; boundary=$B" \
|
||||||
|
--header="Accept: application/json" \
|
||||||
|
--post-file=/tmp/wb.body \
|
||||||
|
{{.Data.UploadURL}}</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy">Add more form fields (<code>password</code>, <code>expires_minutes</code>, …) by repeating the <code>--%s … Content-Disposition: form-data; name="…"</code> block before the closing boundary. If this feels fiddly, <code>curl</code> or the CLI build the body for you.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="ex-httpie" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>HTTPie</h3>
|
||||||
|
<p>Multipart with form fields:</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>http --multipart POST {{.Data.UploadURL}} \
|
||||||
|
Accept:application/json \
|
||||||
|
file@./report.pdf \
|
||||||
|
max_downloads=3 \
|
||||||
|
expires_minutes=1440</code></pre>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="ex-python" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Python (requests)</h3>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>import requests
|
||||||
|
|
||||||
|
with open("report.pdf", "rb") as f:
|
||||||
|
r = requests.post(
|
||||||
|
"{{.Data.UploadURL}}",
|
||||||
|
headers={"Accept": "application/json"}, # add "Authorization": "Bearer <token>"
|
||||||
|
files={"file": f},
|
||||||
|
data={"expires_minutes": 1440, "max_downloads": 5},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
print(r.json()["boxUrl"])</code></pre>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="ex-node" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>Node.js (fetch)</h3>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", new Blob([await readFile("report.pdf")]), "report.pdf");
|
||||||
|
form.set("expires_minutes", "1440");
|
||||||
|
|
||||||
|
const res = await fetch("{{.Data.UploadURL}}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" }, // add Authorization: "Bearer <token>"
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const box = await res.json();
|
||||||
|
console.log(box.boxUrl);</code></pre>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article id="ex-ps" class="card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>PowerShell</h3>
|
||||||
|
<p>PowerShell 7+ has native multipart with <code>-Form</code>:</p>
|
||||||
|
<figure class="code-block">
|
||||||
|
<pre><code>$resp = Invoke-RestMethod -Uri "{{.Data.UploadURL}}" -Method Post -Headers @{ Accept = "application/json" } -Form @{
|
||||||
|
file = Get-Item ".\report.pdf"
|
||||||
|
expires_minutes = 1440
|
||||||
|
}
|
||||||
|
$resp.boxUrl</code></pre>
|
||||||
|
</figure>
|
||||||
|
<p class="muted-copy">On Windows PowerShell 5.1, use the bundled <code>curl.exe</code> (the same approach the <a href="#cli" data-doc-link>CLI</a> takes) or the <code>warpbox.ps1</code> script.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===================== FAQ ===================== -->
|
||||||
|
<section id="faq" class="doc-panel" data-doc-panel="faq" tabindex="-1">
|
||||||
|
<header class="panel-head">
|
||||||
|
<p class="kicker">Help</p>
|
||||||
|
<h2>FAQ & troubleshooting</h2>
|
||||||
|
<p class="lead">Quick answers, each linking back to the relevant part of the docs.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="faq-list">
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Do I need an account or API key?</summary>
|
||||||
|
<p>No. Anonymous uploads work without one, see the <a href="#home" data-doc-link>quickstart</a>. Add a token only to upload as your account and use your account's limits; set one up under <a href="/account/settings">Account, Access tokens</a> and pass it as described in <a href="#cli-auth" data-doc-link>CLI authentication</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How do I send a password, expiry, or download limit?</summary>
|
||||||
|
<p>They're multipart form fields on the upload endpoint: <code>password</code>, <code>expires_minutes</code> (or <code>max_days</code>), and <code>max_downloads</code>. See the full list under <a href="#ep-upload" data-doc-link>Endpoints, request fields</a>, or use the CLI flags in <a href="#cli-usage" data-doc-link>CLI usage</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How do I get file URLs and a delete link back?</summary>
|
||||||
|
<p>Send <code>Accept: application/json</code>. The response includes <code>boxUrl</code>, per-file <code>url</code>s, and the private <code>manageUrl</code>/<code>deleteUrl</code> (shown only once). See <a href="#responses" data-doc-link>the JSON response</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How do I upload one big file reliably?</summary>
|
||||||
|
<p>Use the <a href="#ep-resumable" data-doc-link>resumable endpoints</a>: create a session, PUT chunks, then complete. Interrupted uploads can resume from the last chunk.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Can I upload several files into one shareable link?</summary>
|
||||||
|
<p>Yes. Send the <code>X-Warpbox-Batch</code> header with a shared value within {{.Data.ShareXGroupWindow}}. Details in <a href="#integrations" data-doc-link>Integrations, grouping</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Where's the keep-it-secret way to store my token?</summary>
|
||||||
|
<p>Use the <code>WARPBOX_TOKEN</code> environment variable or <code>--auth-file</code>, not <code>--auth</code> on the command line. Full guidance in <a href="#cli-auth" data-doc-link>CLI authentication</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>My upload returns an error, what do the codes mean?</summary>
|
||||||
|
<p>Errors come back as <code>{ "error": "message" }</code> with a non-2xx status: <code>413</code> too large, <code>429</code> rate limited / over quota, <code>401</code> invalid token. See <a href="#responses" data-doc-link>error responses</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How do I use Warpbox from ShareX?</summary>
|
||||||
|
<p>Import the <code>.sxcu</code> and (optionally) add your token header. Step by step with the config in <a href="#integrations" data-doc-link>Integrations, ShareX setup</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary><code>warpbox: command not found</code> after install?</summary>
|
||||||
|
<p>The install directory isn't on your <code>PATH</code>. Fix it per your platform in <a href="#cli-install" data-doc-link>Install & add to PATH</a>.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Is there a machine-readable schema?</summary>
|
||||||
|
<p>Yes: <a href="{{.Data.RequestSchemaURL}}">upload-request.json</a> and <a href="{{.Data.ResponseSchemaURL}}">upload-response.json</a> (JSON Schema 2020-12).</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="card download-card">
|
<div class="card download-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="file-emblem" aria-hidden="true">
|
<div class="file-emblem" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
|
<span class="svg-icon svg-icon-document"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
||||||
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
||||||
@@ -24,58 +24,112 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Data.Files}}
|
{{if .Data.Files}}
|
||||||
|
{{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}}
|
||||||
|
{{$failed := false}}{{range .Data.Files}}{{if .Failed}}{{$failed = true}}{{end}}{{end}}
|
||||||
|
{{if $processing}}
|
||||||
|
<div class="upload-processing-alert" role="status">
|
||||||
|
Some files are still processing. You can share this link now, but processing files will become available shortly.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $failed}}
|
||||||
|
<div class="upload-processing-alert upload-processing-alert-error" role="alert">
|
||||||
|
Upload processing failed for one or more files. The original upload could not be finalized by the storage backend.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{$single := eq (len .Data.Files) 1}}
|
||||||
<div class="badge-row">
|
<div class="badge-row">
|
||||||
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
|
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
|
||||||
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
|
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if not .Data.Locked}}
|
{{if not .Data.Locked}}
|
||||||
|
<button class="button button-outline button-wide download-share-button" type="button" data-share-box data-share-url="/d/{{.Data.Box.ID}}" data-share-title="{{if .Data.Locked}}Protected Warpbox box{{else}}Warpbox box {{.Data.Box.ID}}{{end}}" data-share-text="Shared files on Warpbox">
|
||||||
|
<span class="svg-icon svg-icon-share" aria-hidden="true"></span>
|
||||||
|
<span data-share-box-label>Share</span>
|
||||||
|
</button>
|
||||||
|
{{if or $processing $failed}}
|
||||||
|
<span class="button button-outline button-wide is-disabled" aria-disabled="true">
|
||||||
|
{{if $failed}}Download unavailable{{else}}Files processing{{end}}
|
||||||
|
</span>
|
||||||
|
{{else}}
|
||||||
|
{{if $single}}
|
||||||
|
{{$first := index .Data.Files 0}}
|
||||||
|
<a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
|
||||||
|
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
|
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||||
Download zip
|
Download zip
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="view-toolbar" aria-label="File view options">
|
<div class="file-browser-window" data-file-browser-window>
|
||||||
<button class="button button-outline is-active" type="button" data-view-button="list">List</button>
|
<div class="file-browser-titlebar">
|
||||||
<button class="button button-outline" type="button" data-view-button="thumbs">Thumbnails</button>
|
<div>
|
||||||
<button class="button button-outline" type="button" data-preview-images>Preview images only</button>
|
<strong>File Browser</strong>
|
||||||
|
<span>{{len .Data.Files}} item{{if ne (len .Data.Files) 1}}s{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="file-browser-window-actions" aria-hidden="true">
|
||||||
<div class="download-list file-browser is-list" data-file-browser>
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-browser-toolbar" aria-label="File view options">
|
||||||
|
<div class="view-toolbar">
|
||||||
|
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
|
||||||
|
<span class="svg-icon svg-icon-list" aria-hidden="true"></span>
|
||||||
|
<span class="sr-only">List view</span>
|
||||||
|
</button>
|
||||||
|
<button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view">
|
||||||
|
<span class="svg-icon svg-icon-grid" aria-hidden="true"></span>
|
||||||
|
<span class="sr-only">Icon view</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-browser-head" aria-hidden="true">
|
||||||
|
<span>Name</span>
|
||||||
|
<span>Type</span>
|
||||||
|
<span>Size</span>
|
||||||
|
</div>
|
||||||
|
<div class="download-list file-browser is-thumbs" data-file-browser>
|
||||||
{{range .Data.Files}}
|
{{range .Data.Files}}
|
||||||
<article class="download-item file-card" data-kind="{{.PreviewKind}}" data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}" data-file-name="{{.Name}}" data-reaction-card>
|
<article class="download-item file-card {{if .Processing}}is-processing{{end}} {{if .Failed}}is-failed{{end}}" data-kind="{{.PreviewKind}}" {{if and (not .Processing) (not .Failed)}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}">
|
||||||
<a class="thumb-link" href="{{.DownloadURL}}?inline=1" aria-label="View {{.Name}}">
|
{{if or .Processing .Failed}}<div class="file-open" aria-label="{{.Name}} {{if .Failed}}failed processing{{else}}is processing{{end}}">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}}
|
||||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
<span class="file-media">
|
||||||
</a>
|
{{if .HasThumbnail}}
|
||||||
<a class="file-main" href="{{.DownloadURL}}?inline=1">
|
<img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||||
|
{{else}}
|
||||||
|
{{if .IconURL}}<img class="file-icon file-icon-standard" src="{{.IconURL}}" alt="" loading="lazy">{{end}}
|
||||||
|
{{if .IconRetroURL}}<img class="file-icon file-icon-retro" src="{{.IconRetroURL}}" alt="" loading="lazy">{{end}}
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="file-main">
|
||||||
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
|
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
|
||||||
<small>{{.Size}} · {{.ContentType}}</small>
|
<small>{{.Size}} · {{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small>
|
||||||
</a>
|
{{if .Failed}}<small class="file-error">{{.Error}}</small>{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="file-type">{{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span>
|
||||||
|
<span class="file-size">{{.Size}}</span>
|
||||||
|
{{if or .Processing .Failed}}</div>{{else}}</a>{{end}}
|
||||||
{{if not $.Data.Locked}}
|
{{if not $.Data.Locked}}
|
||||||
<div class="file-actions">
|
|
||||||
<a class="button button-outline preview-action" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer" data-preview-action data-view-label="View" data-copy-label="Copy link">
|
|
||||||
<svg data-preview-view-icon viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" /><circle cx="12" cy="12" r="3" /></svg>
|
|
||||||
<svg data-preview-copy-icon viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true" hidden><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
|
||||||
<span data-preview-label>View</span>
|
|
||||||
</a>
|
|
||||||
<a class="button button-outline" href="{{.DownloadURL}}" download="{{.Name}}">
|
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="file-reaction-dock" data-reaction-dock>
|
<div class="file-reaction-dock" data-reaction-dock>
|
||||||
<div class="file-reactions" data-reaction-list>
|
<div class="file-reactions" data-reaction-list>
|
||||||
{{range .Reactions}}
|
{{range .Reactions}}
|
||||||
<span class="reaction-pill" title="{{.Label}}">
|
<button class="reaction-pill {{if not .Visible}}is-hidden-summary{{end}}" type="button" title="{{.Label}}" data-reaction-pill data-reaction-emoji-id="{{.EmojiID}}" data-reaction-label="{{.Label}}" data-reaction-url="{{.URL}}" data-reaction-count="{{.Count}}" aria-label="{{if $.Data.Locked}}Reaction{{else}}React with {{.Label}}{{end}}">
|
||||||
<img src="{{.URL}}" alt="{{.Label}}" loading="lazy">
|
<img src="{{.URL}}" alt="{{.Label}}" loading="lazy">
|
||||||
<span>{{.Count}}</span>
|
<span>{{.Count}}</span>
|
||||||
</span>
|
</button>
|
||||||
|
{{end}}
|
||||||
|
{{if .ReactionMore}}
|
||||||
|
<button class="reaction-more" type="button" data-reaction-more aria-label="Show all reactions">+{{.ReactionMore}}</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if not .Reacted}}
|
{{if not .Reacted}}
|
||||||
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
|
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9Z" /><path d="M8 14s1.4 2 4 2 4-2 4-2" /><path d="M9 9h.01M15 9h.01" /></svg>
|
<span class="svg-icon svg-icon-emoji" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,6 +137,7 @@
|
|||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{if not .Data.Locked}}
|
{{if not .Data.Locked}}
|
||||||
<div class="reaction-picker" data-reaction-picker hidden>
|
<div class="reaction-picker" data-reaction-picker hidden>
|
||||||
<div class="reaction-picker-panel" role="dialog" aria-modal="false" aria-label="Choose a reaction">
|
<div class="reaction-picker-panel" role="dialog" aria-modal="false" aria-label="Choose a reaction">
|
||||||
@@ -90,6 +145,11 @@
|
|||||||
<strong>React</strong>
|
<strong>React</strong>
|
||||||
<button class="button button-ghost reaction-picker-close" type="button" data-reaction-close aria-label="Close reaction picker">Close</button>
|
<button class="button button-ghost reaction-picker-close" type="button" data-reaction-close aria-label="Close reaction picker">Close</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="reaction-existing" data-reaction-existing hidden>
|
||||||
|
<small>Existing reactions</small>
|
||||||
|
<div class="reaction-existing-list" data-reaction-existing-list></div>
|
||||||
|
</div>
|
||||||
|
<p class="reaction-readonly-note" data-reaction-readonly hidden>You already reacted to this file.</p>
|
||||||
<div class="reaction-picker-tabs" role="tablist" aria-label="Emoji themes">
|
<div class="reaction-picker-tabs" role="tablist" aria-label="Emoji themes">
|
||||||
{{range $index, $tab := .Data.EmojiTabs}}
|
{{range $index, $tab := .Data.EmojiTabs}}
|
||||||
<button type="button" class="reaction-tab {{if eq $index 0}}is-active{{end}}" data-reaction-tab="{{$tab.ID}}" role="tab" aria-selected="{{if eq $index 0}}true{{else}}false{{end}}">{{$tab.Label}}</button>
|
<button type="button" class="reaction-tab {{if eq $index 0}}is-active{{end}}" data-reaction-tab="{{$tab.ID}}" role="tab" aria-selected="{{if eq $index 0}}true{{else}}false{{end}}">{{$tab.Label}}</button>
|
||||||
@@ -117,35 +177,35 @@
|
|||||||
<small>File actions</small>
|
<small>File actions</small>
|
||||||
<div class="context-menu-icons" aria-label="Quick actions">
|
<div class="context-menu-icons" aria-label="Quick actions">
|
||||||
<button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview">
|
<button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
<span class="svg-icon svg-icon-open" aria-hidden="true"></span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL">
|
<button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||||
<span data-context-label class="sr-only">Copy</span>
|
<span data-context-label class="sr-only">Copy</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" role="menuitem" data-context-action="preview">
|
<button type="button" role="menuitem" data-context-action="preview">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" /><circle cx="12" cy="12" r="3" /></svg>
|
<span class="svg-icon svg-icon-eye" aria-hidden="true"></span>
|
||||||
<span data-context-label>Preview</span>
|
<span data-context-label>Preview</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-context-action="view">
|
<button type="button" role="menuitem" data-context-action="view">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
<span class="svg-icon svg-icon-open" aria-hidden="true"></span>
|
||||||
<span data-context-label>View raw file</span>
|
<span data-context-label>View raw file</span>
|
||||||
</button>
|
</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" role="menuitem" data-context-action="copy-preview">
|
<button type="button" role="menuitem" data-context-action="copy-preview">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||||
<span data-context-label>Copy Preview</span>
|
<span data-context-label>Copy Preview</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="menuitem" data-context-action="copy-download">
|
<button type="button" role="menuitem" data-context-action="copy-download">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||||
<span data-context-label>Copy Download</span>
|
<span data-context-label>Copy Download</span>
|
||||||
</button>
|
</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button type="button" role="menuitem" data-context-action="download">
|
<button type="button" role="menuitem" data-context-action="download">
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||||
<span data-context-label>Download</span>
|
<span data-context-label>Download</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||