Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 | |||
| f9755fa98f | |||
| 2eba04b9da | |||
| 81f4ce5e36 | |||
| eff831b142 | |||
| 3b278642dc | |||
| 3a0dd04e61 | |||
| e17c5e92a7 | |||
| f698ba516d | |||
| 17c31be8b4 | |||
| 313c89483c | |||
| 5cd476e7f3 | |||
| d3b6a86753 | |||
| cf5d8bb50d | |||
| 8e3f783780 | |||
| 6c87187c6d | |||
| f628b489af |
@@ -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
|
||||||
|
|||||||
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.
|
||||||
337
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,40 +61,184 @@ Upload policy defaults are also configured in megabytes and can later be changed
|
|||||||
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
|
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
|
||||||
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
|
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
|
||||||
- `WARPBOX_USER_STORAGE_BACKEND=local`
|
- `WARPBOX_USER_STORAGE_BACKEND=local`
|
||||||
- `WARPBOX_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.
|
||||||
|
|
||||||
For one-off Go commands, run them from the backend module:
|
## Uploads
|
||||||
|
|
||||||
|
Browser uploads use Warpbox native resumable uploads by default. Resumable behavior is configurable
|
||||||
|
from `/admin/settings`, including enable/disable, chunk size, retention, and whether chunks use the
|
||||||
|
default local temp path or a custom local path such as a fast SSD. When all chunks arrive, Warpbox
|
||||||
|
returns the share link immediately and marks files as `Processing` until the background finalizer
|
||||||
|
streams them into the selected storage backend. Draft chunks are deleted once finalization succeeds.
|
||||||
|
Expired uploading drafts are cleaned after the configured retention window; sessions already in
|
||||||
|
`Processing` are protected from cleanup while finalization is running.
|
||||||
|
|
||||||
|
### Anonymous uploads
|
||||||
|
|
||||||
|
Anonymous uploads return a private management link at creation time. Keep that link secret: anyone
|
||||||
|
with it can delete the entire upload box. The raw delete token is not stored and cannot be recovered
|
||||||
|
later. Browser uploads show `Open box` and `Copy URL` as the primary actions, with a smaller
|
||||||
|
`Manage or delete this upload` link in the completion panel.
|
||||||
|
|
||||||
|
### `curl` and ShareX
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
# Terminal-friendly output: one plain box URL.
|
||||||
go run ./cmd/warpbox
|
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
||||||
|
|
||||||
|
# JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
||||||
|
curl -F sharex=@./screenshot.png \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
http://localhost:8080/api/v1/upload
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker / Podman
|
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start from
|
||||||
|
`examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
||||||
|
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header mint a
|
||||||
|
token under **Account → Access tokens**. The JSON response uses ShareX placeholders `{json:boxUrl}`
|
||||||
|
(URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and `{json:error}` (error
|
||||||
|
message).
|
||||||
|
|
||||||
|
### Grouping multiple files into one box (`X-Warpbox-Batch`)
|
||||||
|
|
||||||
|
By default every uploaded file becomes its own box. To put several files in a **single** box, send
|
||||||
|
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
|
||||||
|
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
|
||||||
|
ShareX selection which ShareX sends as separate back-to-back requests land as one shareable link.
|
||||||
|
The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file. Requests
|
||||||
|
without the header behave exactly as before.
|
||||||
|
|
||||||
|
### Resumable API flow
|
||||||
|
|
||||||
|
Custom clients can use the resumable JSON API for large files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a resumable session from file metadata.
|
||||||
|
curl -s http://localhost:8080/api/v1/uploads/resumable \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"files":[{"name":"large.bin","size":1048576,"contentType":"application/octet-stream"}],"expiresMinutes":1440}'
|
||||||
|
|
||||||
|
# 2. Upload exact-sized chunks using the returned sessionId, file id, and chunkSize.
|
||||||
|
dd if=./large.bin bs=8388608 count=1 skip=0 2>/dev/null | \
|
||||||
|
curl -X PUT --data-binary @- \
|
||||||
|
http://localhost:8080/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
|
||||||
|
|
||||||
|
# 3. Complete the session after all chunks are present.
|
||||||
|
curl -X POST -H 'Accept: application/json' \
|
||||||
|
http://localhost:8080/api/v1/uploads/resumable/SESSION_ID/complete
|
||||||
|
```
|
||||||
|
|
||||||
|
The complete response is the same JSON shape as `POST /api/v1/upload`, including `boxUrl`,
|
||||||
|
`manageUrl`, `deleteUrl`, and file URLs. Send `Authorization: Bearer <token>` on every resumable
|
||||||
|
request to upload as an account.
|
||||||
|
|
||||||
|
## Accounts and admin
|
||||||
|
|
||||||
|
- `/register` bootstraps the first admin account only when no users exist.
|
||||||
|
- `/login` and `/logout` provide cookie-based web sessions.
|
||||||
|
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
|
||||||
|
history, and flat collections. Uploading still happens from the homepage.
|
||||||
|
- `/admin` shows overview metrics: boxes, files, storage, recent uploads, protected/expired boxes.
|
||||||
|
- `/admin/files` is a recent upload table with view and delete actions.
|
||||||
|
- `/admin/users` lets admins create invite links, disable/reactivate users, generate reset links,
|
||||||
|
view storage/daily usage, and set per-user storage quota overrides.
|
||||||
|
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
|
||||||
|
user storage quota, and usage retention.
|
||||||
|
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
|
||||||
|
- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and
|
||||||
|
repeated login failures. Auto-ban is off by default and configured from the admin UI.
|
||||||
|
|
||||||
|
Logged-in browser uploads from `/` use `POST /api/v1/upload`, and the resulting box is stored with
|
||||||
|
owner and optional collection metadata. Admin users are exempt from the global max upload size on the
|
||||||
|
homepage upload flow.
|
||||||
|
|
||||||
|
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
|
||||||
|
support will power public forgot-password and optional email delivery.
|
||||||
|
|
||||||
|
## Emoji reaction packs
|
||||||
|
|
||||||
|
File reactions use emoji packs from the runtime data directory, not from the application code. By
|
||||||
|
default that means `./data/emoji`; if you change `WARPBOX_DATA_DIR`, use `$WARPBOX_DATA_DIR/emoji`
|
||||||
|
instead.
|
||||||
|
|
||||||
|
Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files
|
||||||
|
directly inside the pack folder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/
|
||||||
|
├── db/
|
||||||
|
├── files/
|
||||||
|
├── logs/
|
||||||
|
└── emoji/
|
||||||
|
├── openmoji/
|
||||||
|
│ ├── 1F600.svg
|
||||||
|
│ ├── 1F44D.svg
|
||||||
|
│ └── 2764.svg
|
||||||
|
├── pixel-pack/
|
||||||
|
│ ├── happy.webp
|
||||||
|
│ ├── fire.webp
|
||||||
|
│ └── star.webp
|
||||||
|
└── custom-work/
|
||||||
|
├── approved.png
|
||||||
|
└── shipped.png
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`. Supported
|
||||||
|
emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Docker / Podman
|
||||||
|
|
||||||
Copy the example environment file and adjust values such as `WARPBOX_BASE_URL` and
|
Copy the example environment file and adjust values such as `WARPBOX_BASE_URL` and
|
||||||
`WARPBOX_ADMIN_TOKEN` before running the container:
|
`WARPBOX_ADMIN_TOKEN` before running the container. Copy the example
|
||||||
|
[docker-compose.example.yml](./docker-compose.example.yml) to
|
||||||
Copy the example [docker-compose.example.yml](./docker-compose.example.yml) to [docker-compose.yml](./docker-compose.yml), modify as need-be
|
[docker-compose.yml](./docker-compose.yml), modify as need-be:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -74,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:
|
||||||
|
|
||||||
@@ -150,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.
|
||||||
@@ -158,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.
|
||||||
@@ -167,92 +353,7 @@ 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
|
|
||||||
|
|
||||||
Anonymous uploads now 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.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -11,27 +11,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AppName string
|
AppName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
Environment string
|
Environment string
|
||||||
Addr string
|
Addr string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
DataDir string
|
DataDir string
|
||||||
AdminToken string
|
AdminToken string
|
||||||
StaticDir string
|
StaticDir string
|
||||||
TemplateDir string
|
TemplateDir string
|
||||||
ReadHeaderTimeout time.Duration
|
ReadHeaderTimeout time.Duration
|
||||||
ReadTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
JobsEnabled bool
|
JobsEnabled bool
|
||||||
CleanupEnabled bool
|
CleanupEnabled bool
|
||||||
CleanupEvery time.Duration
|
CleanupEvery time.Duration
|
||||||
ThumbnailEnabled bool
|
ThumbnailEnabled bool
|
||||||
ThumbnailEvery time.Duration
|
ThumbnailEvery time.Duration
|
||||||
MaxUploadSize int64
|
ResumableUploadsEnabled bool
|
||||||
DefaultSettings SettingsDefaults
|
ResumableChunkSize int64
|
||||||
|
ResumableRetention time.Duration
|
||||||
|
MaxUploadSize int64
|
||||||
|
DefaultSettings SettingsDefaults
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsDefaults struct {
|
type SettingsDefaults struct {
|
||||||
@@ -52,30 +55,38 @@ type SettingsDefaults struct {
|
|||||||
ShortWindowSeconds int
|
ShortWindowSeconds int
|
||||||
AnonymousStorageBackend string
|
AnonymousStorageBackend string
|
||||||
UserStorageBackend string
|
UserStorageBackend string
|
||||||
|
ResumableUploadsEnabled bool
|
||||||
|
ResumableChunkSizeMB float64
|
||||||
|
ResumableRetentionHours int
|
||||||
|
ResumableChunkMode string
|
||||||
|
ResumableChunkPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
|
||||||
AppVersion: envString("APP_VERSION", "dev"),
|
AppVersion: envString("APP_VERSION", "dev"),
|
||||||
Environment: envString("WARPBOX_ENV", "development"),
|
Environment: envString("WARPBOX_ENV", "development"),
|
||||||
Addr: envString("WARPBOX_ADDR", ":8080"),
|
Addr: envString("WARPBOX_ADDR", ":8080"),
|
||||||
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
|
||||||
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
|
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
|
||||||
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
|
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
|
||||||
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
|
||||||
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
|
||||||
ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
|
ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
|
||||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
|
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
|
||||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
|
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
|
||||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||||
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
||||||
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
||||||
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
||||||
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
||||||
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
|
||||||
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
|
||||||
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
|
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.
|
||||||
DefaultSettings: SettingsDefaults{
|
DefaultSettings: SettingsDefaults{
|
||||||
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
|
||||||
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ type App struct {
|
|||||||
uploadService *services.UploadService
|
uploadService *services.UploadService
|
||||||
authService *services.AuthService
|
authService *services.AuthService
|
||||||
settingsService *services.SettingsService
|
settingsService *services.SettingsService
|
||||||
|
reactionService *services.ReactionService
|
||||||
banService *services.BanService
|
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, 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,
|
||||||
@@ -29,9 +35,11 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
|||||||
uploadService: uploadService,
|
uploadService: uploadService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
settingsService: settingsService,
|
settingsService: settingsService,
|
||||||
|
reactionService: reactionService,
|
||||||
banService: banService,
|
banService: banService,
|
||||||
rateLimiter: newRateLimiter(),
|
rateLimiter: newRateLimiter(),
|
||||||
uploadGroups: newUploadGrouper(),
|
uploadGroups: newUploadGrouper(),
|
||||||
|
fileIcons: fileIcons,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,16 +129,34 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
||||||
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
|
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
|
||||||
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
||||||
|
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing)
|
||||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||||
|
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||||
|
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
||||||
mux.HandleFunc("GET /health", a.Health)
|
mux.HandleFunc("GET /health", a.Health)
|
||||||
mux.HandleFunc("GET /healthz", a.Health)
|
mux.HandleFunc("GET /healthz", notFound)
|
||||||
mux.HandleFunc("GET /api/v1/health", a.Health)
|
mux.HandleFunc("GET /api/v1/health", notFound)
|
||||||
mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig)
|
mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig)
|
||||||
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
|
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
|
||||||
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
|
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
|
||||||
mux.HandleFunc("POST /api/v1/upload", a.Upload)
|
mux.HandleFunc("POST /api/v1/upload", a.Upload)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable", a.CreateResumableUpload)
|
||||||
|
mux.HandleFunc("GET /api/v1/uploads/resumable/{sessionID}", a.ResumableUploadStatus)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/files", a.AddResumableFiles)
|
||||||
|
mux.HandleFunc("PUT /api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}", a.PutResumableChunk)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/complete", a.CompleteResumableUpload)
|
||||||
|
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/complete-uploaded", a.CompleteUploadedResumableUpload)
|
||||||
|
mux.HandleFunc("DELETE /api/v1/uploads/resumable/{sessionID}", a.CancelResumableUpload)
|
||||||
|
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
|
||||||
mux.Handle("GET /static/", a.Static())
|
mux.Handle("GET /static/", a.Static())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func notFound(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/helpers"
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
@@ -26,6 +30,7 @@ type downloadPageData struct {
|
|||||||
DownloadCount int
|
DownloadCount int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
ExpiresLabel string
|
ExpiresLabel string
|
||||||
|
EmojiTabs []emojiTabView
|
||||||
}
|
}
|
||||||
|
|
||||||
type boxView struct {
|
type boxView struct {
|
||||||
@@ -36,11 +41,44 @@ type fileView struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Size string
|
Size string
|
||||||
|
SizeBytes int64
|
||||||
ContentType string
|
ContentType string
|
||||||
PreviewKind string
|
PreviewKind string
|
||||||
URL string
|
URL string
|
||||||
DownloadURL string
|
DownloadURL string
|
||||||
ThumbnailURL string
|
ThumbnailURL string
|
||||||
|
SceneURL string
|
||||||
|
ArchiveURL string
|
||||||
|
HasThumbnail bool
|
||||||
|
HasScene bool
|
||||||
|
HasArchive bool
|
||||||
|
IconURL string
|
||||||
|
IconRetroURL string
|
||||||
|
ReactURL string
|
||||||
|
Reactions []reactionView
|
||||||
|
ReactionMore int
|
||||||
|
Reacted bool
|
||||||
|
Processing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type reactionView struct {
|
||||||
|
EmojiID string `json:"emojiId"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Visible bool `json:"visible"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type emojiTabView struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Emojis []emojiOptionView
|
||||||
|
}
|
||||||
|
|
||||||
|
type emojiOptionView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Label string `json:"label"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type previewPageData struct {
|
type previewPageData struct {
|
||||||
@@ -70,26 +108,69 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||||
|
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
||||||
|
file := box.Files[0]
|
||||||
|
if file.Processing {
|
||||||
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if shouldServeRawSocialMedia(file) {
|
||||||
|
a.serveFileContent(w, r, box, file, false)
|
||||||
|
a.logger.Info("single-file media served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visitorID := a.reactionVisitorID(w, r)
|
||||||
|
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
|
||||||
files := make([]fileView, 0, len(box.Files))
|
files := make([]fileView, 0, len(box.Files))
|
||||||
if !(locked && box.Obfuscate) {
|
if !(locked && box.Obfuscate) {
|
||||||
for _, file := range box.Files {
|
for _, file := range box.Files {
|
||||||
files = append(files, a.fileView(box, file))
|
files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
emojiTabs, err := a.emojiTabs()
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("failed to load emoji tabs", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4301, "box_id", box.ID, "error", err.Error())...)
|
||||||
|
}
|
||||||
|
|
||||||
expiresLabel := 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,
|
||||||
@@ -99,6 +180,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
DownloadCount: box.DownloadCount,
|
DownloadCount: box.DownloadCount,
|
||||||
MaxDownloads: box.MaxDownloads,
|
MaxDownloads: box.MaxDownloads,
|
||||||
ExpiresLabel: expiresLabel,
|
ExpiresLabel: expiresLabel,
|
||||||
|
EmojiTabs: emojiTabs,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
|
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
|
||||||
@@ -111,6 +193,43 @@ func plural(n int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldServeRawSocialMedia(file services.File) bool {
|
||||||
|
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
|
||||||
|
if strings.TrimSpace(contentType) == "" {
|
||||||
|
contentType = "file"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
|
if file.PreviewKind == "video" && view.HasThumbnail {
|
||||||
|
return absoluteURL(r, view.ThumbnailURL)
|
||||||
|
}
|
||||||
|
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageType(file services.File) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialOGType(file services.File) string {
|
||||||
|
switch file.PreviewKind {
|
||||||
|
case "video":
|
||||||
|
return "video.other"
|
||||||
|
default:
|
||||||
|
return "website"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -118,20 +237,50 @@ 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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
ImageURL: imageURL,
|
CanonicalURL: pageURL,
|
||||||
|
Robots: web.RobotsNone,
|
||||||
|
OGType: ogType,
|
||||||
|
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,
|
||||||
@@ -143,6 +292,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
|
||||||
@@ -152,12 +302,17 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -169,6 +324,17 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
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
|
||||||
@@ -183,6 +349,161 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -278,6 +599,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"))...)
|
||||||
@@ -310,18 +632,204 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) fileView(box services.Box, file services.File) fileView {
|
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||||
|
return a.fileViewWithReactions(box, file, nil, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView {
|
||||||
|
icon := a.fileIcons.lookup(file.Name, file.ContentType)
|
||||||
|
reactionViews := a.reactionViews(reactions)
|
||||||
return fileView{
|
return fileView{
|
||||||
ID: file.ID,
|
ID: file.ID,
|
||||||
Name: file.Name,
|
Name: file.Name,
|
||||||
Size: helpers.FormatBytes(file.Size),
|
Size: helpers.FormatBytes(file.Size),
|
||||||
|
SizeBytes: file.Size,
|
||||||
ContentType: file.ContentType,
|
ContentType: file.ContentType,
|
||||||
PreviewKind: file.PreviewKind,
|
PreviewKind: file.PreviewKind,
|
||||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||||
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
||||||
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
||||||
|
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
|
||||||
|
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
|
||||||
|
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
|
||||||
|
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
|
||||||
|
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file),
|
||||||
|
IconURL: fileIconURL("standard", icon.Standard),
|
||||||
|
IconRetroURL: fileIconURL("retro", icon.Retro),
|
||||||
|
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
||||||
|
Reactions: reactionViews,
|
||||||
|
ReactionMore: reactionOverflowCount(reactionViews),
|
||||||
|
Reacted: reacted,
|
||||||
|
Processing: file.Processing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||||
|
http.Error(w, "password required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid reaction", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emojiID := strings.TrimSpace(r.FormValue("emoji_id"))
|
||||||
|
if !a.validEmojiID(emojiID) {
|
||||||
|
http.Error(w, "unknown emoji", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visitorID := a.reactionVisitorID(w, r)
|
||||||
|
reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID)
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||||
|
http.Error(w, "could not save reaction", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...)
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"reactions": a.reactionViews(reactions),
|
||||||
|
"reacted": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
|
||||||
|
views := make([]reactionView, 0, len(reactions))
|
||||||
|
for index, reaction := range reactions {
|
||||||
|
views = append(views, reactionView{
|
||||||
|
EmojiID: reaction.EmojiID,
|
||||||
|
URL: emojiURL(reaction.EmojiID),
|
||||||
|
Label: emojiLabel(reaction.EmojiID),
|
||||||
|
Count: reaction.Count,
|
||||||
|
Visible: index < 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionOverflowCount(reactions []reactionView) int {
|
||||||
|
if len(reactions) <= 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(reactions) - 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emojiTabs() ([]emojiTabView, error) {
|
||||||
|
root := a.emojiRoot()
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tabs := make([]emojiTabView, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tabID := entry.Name()
|
||||||
|
files, err := os.ReadDir(filepath.Join(root, tabID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)}
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() || !isEmojiFile(file.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emojiID := tabID + "/" + file.Name()
|
||||||
|
tab.Emojis = append(tab.Emojis, emojiOptionView{
|
||||||
|
ID: emojiID,
|
||||||
|
URL: emojiURL(emojiID),
|
||||||
|
Label: emojiLabel(emojiID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID })
|
||||||
|
if len(tab.Emojis) > 0 {
|
||||||
|
tabs = append(tabs, tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
|
||||||
|
return tabs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) validEmojiID(id string) bool {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(id, "/")
|
||||||
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1]))
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) emojiRoot() string {
|
||||||
|
return filepath.Join(a.cfg.DataDir, "emoji")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string {
|
||||||
|
const cookieName = "warpbox_reactor"
|
||||||
|
if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
visitorID := services.RandomPublicToken(32)
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: cookieName,
|
||||||
|
Value: visitorID,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
Expires: time.Now().AddDate(1, 0, 0),
|
||||||
|
})
|
||||||
|
return visitorID
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmojiFile(name string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(name))
|
||||||
|
return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
|
||||||
|
}
|
||||||
|
|
||||||
|
func emojiTabLabel(id string) string {
|
||||||
|
label := strings.NewReplacer("-", " ", "_", " ").Replace(id)
|
||||||
|
if label == "" {
|
||||||
|
return "Emoji"
|
||||||
|
}
|
||||||
|
return strings.ToUpper(label[:1]) + label[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func emojiLabel(id string) string {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id))
|
||||||
|
return strings.ReplaceAll(base, "-", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func emojiURL(id string) string {
|
||||||
|
parts := strings.Split(id, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
|
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
|
||||||
if !a.uploadService.IsProtected(box) {
|
if !a.uploadService.IsProtected(box) {
|
||||||
return true
|
return true
|
||||||
@@ -362,3 +870,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,16 +13,20 @@ 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) {
|
response := httptest.NewRecorder()
|
||||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
|
||||||
response := httptest.NewRecorder()
|
|
||||||
|
|
||||||
mux.ServeHTTP(response, request)
|
mux.ServeHTTP(response, request)
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||||
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
||||||
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||||
Title: "Upload your files",
|
Title: "Upload your files",
|
||||||
Description: "Upload and share files through a self-hosted Warpbox instance.",
|
Description: "Upload and share files fast. Drop a file, get a link — private, temporary transfers that expire on your terms.",
|
||||||
CurrentUser: currentUser,
|
CanonicalURL: absoluteURL(r, "/"),
|
||||||
|
ImageURL: absoluteURL(r, "/static/og-default.png"),
|
||||||
|
ImageAlt: "Warp Box — simple file sharing and fast downloads",
|
||||||
|
CurrentUser: currentUser,
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
MaxUploadSize: maxUploadSize,
|
MaxUploadSize: maxUploadSize,
|
||||||
LimitSummary: limitSummary,
|
LimitSummary: limitSummary,
|
||||||
@@ -95,7 +98,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 {
|
||||||
|
|||||||
427
backend/libs/handlers/resumable.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
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 || session.Status == services.ResumableStatusProcessing {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -15,6 +16,24 @@ func (a *App) Static() http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pack := strings.TrimSpace(r.PathValue("pack"))
|
||||||
|
file := strings.TrimSpace(r.PathValue("file"))
|
||||||
|
if pack == "" || file == "" || strings.Contains(pack, "/") || strings.Contains(pack, "\\") || strings.Contains(pack, "..") || strings.Contains(file, "/") || strings.Contains(file, "\\") || strings.Contains(file, "..") || !isEmojiFile(file) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(a.emojiRoot(), pack, file)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStaticCacheHeaders(w, r.URL.Path)
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
|
||||||
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
|
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
|
||||||
|
|||||||
@@ -228,11 +228,22 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
|
|||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -10,8 +11,10 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -46,6 +49,42 @@ func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileReactionCanBeAddedOncePerVisitor(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
if len(payload.Files) != 1 {
|
||||||
|
t.Fatalf("uploaded files = %d", len(payload.Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.ReactToFile(response, request)
|
||||||
|
if response.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("first reaction status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), `"count":1`) {
|
||||||
|
t.Fatalf("reaction response missing count: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
retry := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
|
||||||
|
retry.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
retry.SetPathValue("boxID", payload.BoxID)
|
||||||
|
retry.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
for _, cookie := range response.Result().Cookies() {
|
||||||
|
retry.AddCookie(cookie)
|
||||||
|
}
|
||||||
|
retryResponse := httptest.NewRecorder()
|
||||||
|
app.ReactToFile(retryResponse, retry)
|
||||||
|
if retryResponse.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("second reaction status = %d, body = %s", retryResponse.Code, retryResponse.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -67,6 +106,452 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
|
||||||
|
request.Header.Set("User-Agent", "Discordbot/2.0")
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadPage(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
|
||||||
|
t.Fatalf("download page did not render text thumbnail image: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||||
|
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
request.Header.Set("User-Agent", "TelegramBot")
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
|
||||||
|
t.Fatalf("social preview body missing twitter card metadata: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
uploadResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(uploadResponse, request)
|
||||||
|
if uploadResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
|
||||||
|
previewRequest.SetPathValue("boxID", payload.BoxID)
|
||||||
|
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, previewRequest)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(response.Body.String(), "preview-title") {
|
||||||
|
t.Fatalf("image social preview bot received HTML preview page")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
|
||||||
|
t.Fatalf("image social preview body = %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-size-bytes="5"`,
|
||||||
|
`data-source-url="/d/` + payload.BoxID,
|
||||||
|
`data-download-url="/d/` + payload.BoxID,
|
||||||
|
`data-icon-url="/static/file-icons/`,
|
||||||
|
`data-preview-tabs`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("preview page missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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()
|
||||||
@@ -178,13 +663,16 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
cfg := config.Config{
|
cfg := config.Config{
|
||||||
AppName: "warpbox.dev",
|
AppName: "warpbox.dev",
|
||||||
AppVersion: "test",
|
AppVersion: "test",
|
||||||
BaseURL: "http://example.test",
|
BaseURL: "http://example.test",
|
||||||
DataDir: filepath.Join(root, "data"),
|
DataDir: filepath.Join(root, "data"),
|
||||||
StaticDir: staticDir,
|
StaticDir: staticDir,
|
||||||
TemplateDir: templateDir,
|
TemplateDir: templateDir,
|
||||||
MaxUploadSize: 1024 * 1024,
|
MaxUploadSize: 1024 * 1024,
|
||||||
|
ResumableUploadsEnabled: true,
|
||||||
|
ResumableChunkSize: 4,
|
||||||
|
ResumableRetention: time.Hour,
|
||||||
DefaultSettings: config.SettingsDefaults{
|
DefaultSettings: config.SettingsDefaults{
|
||||||
AnonymousUploadsEnabled: true,
|
AnonymousUploadsEnabled: true,
|
||||||
AnonymousMaxUploadMB: 1,
|
AnonymousMaxUploadMB: 1,
|
||||||
@@ -192,12 +680,24 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
UserDailyUploadMB: 8,
|
UserDailyUploadMB: 8,
|
||||||
DefaultUserStorageMB: 16,
|
DefaultUserStorageMB: 16,
|
||||||
UsageRetentionDays: 30,
|
UsageRetentionDays: 30,
|
||||||
|
ResumableUploadsEnabled: true,
|
||||||
|
ResumableChunkSizeMB: 0.000003814697265625,
|
||||||
|
ResumableRetentionHours: 1,
|
||||||
|
ResumableChunkMode: "same",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewUploadService returned error: %v", err)
|
t.Fatalf("NewUploadService returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(cfg.DataDir, "emoji", "openmoji"), 0o755); err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("create emoji test dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(cfg.DataDir, "emoji", "openmoji", "1F600.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>`), 0o644); err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("write emoji test file: %v", err)
|
||||||
|
}
|
||||||
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
|
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
service.Close()
|
service.Close()
|
||||||
@@ -213,12 +713,17 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
service.Close()
|
service.Close()
|
||||||
t.Fatalf("NewSettingsService returned error: %v", err)
|
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
reactionService, err := services.NewReactionService(service.DB())
|
||||||
|
if err != nil {
|
||||||
|
service.Close()
|
||||||
|
t.Fatalf("NewReactionService returned error: %v", err)
|
||||||
|
}
|
||||||
banService, err := services.NewBanService(service.DB())
|
banService, err := services.NewBanService(service.DB())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
service.Close()
|
service.Close()
|
||||||
t.Fatalf("NewBanService returned error: %v", err)
|
t.Fatalf("NewBanService returned error: %v", err)
|
||||||
}
|
}
|
||||||
return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() {
|
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
|
||||||
if err := service.Close(); err != nil {
|
if err := service.Close(); err != nil {
|
||||||
t.Fatalf("Close returned error: %v", err)
|
t.Fatalf("Close returned error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -293,6 +798,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 {
|
||||||
|
|||||||
@@ -32,13 +32,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
|||||||
uploadService.Close()
|
uploadService.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
reactionService, err := services.NewReactionService(uploadService.DB())
|
||||||
|
if err != nil {
|
||||||
|
uploadService.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
banService, err := services.NewBanService(uploadService.DB())
|
banService, err := services.NewBanService(uploadService.DB())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
uploadService.Close()
|
uploadService.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
|
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
|
||||||
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService)
|
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, reactionService, banService)
|
||||||
|
|
||||||
router := http.NewServeMux()
|
router := http.NewServeMux()
|
||||||
app.RegisterRoutes(router)
|
app.RegisterRoutes(router)
|
||||||
|
|||||||
@@ -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,20 +1,32 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -101,25 +113,56 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
changed := false
|
changed := false
|
||||||
for i := range box.Files {
|
for i := range box.Files {
|
||||||
file := &box.Files[i]
|
file := &box.Files[i]
|
||||||
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
||||||
|
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
||||||
|
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
||||||
|
if !needsPrimary && !needsScenes && !needsArchive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Scanned++
|
result.Scanned++
|
||||||
|
|
||||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
if needsPrimary {
|
||||||
if err != nil {
|
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
if err != nil {
|
||||||
result.Failed++
|
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||||
continue
|
result.Failed++
|
||||||
}
|
} else if thumbnail == "" {
|
||||||
if thumbnail == "" {
|
result.Failed++
|
||||||
result.Failed++
|
} else {
|
||||||
continue
|
file.Thumbnail = thumbnail
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file.Thumbnail = thumbnail
|
if needsScenes {
|
||||||
changed = true
|
sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
|
||||||
result.Generated++
|
if err != nil {
|
||||||
|
logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error())
|
||||||
|
result.Failed++
|
||||||
|
} else if sceneThumbnail == "" {
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
file.SceneThumbnail = sceneThumbnail
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsArchive {
|
||||||
|
archiveListing, err := generateArchiveListing(uploadService, box, *file)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
|
||||||
|
result.Failed++
|
||||||
|
} else if archiveListing == "" {
|
||||||
|
result.Failed++
|
||||||
|
} else {
|
||||||
|
file.ArchiveListing = archiveListing
|
||||||
|
file.ArchiveListingObjectKey = ""
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
@@ -131,7 +174,35 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func needsThumbnail(file services.File) bool {
|
func needsThumbnail(file services.File) bool {
|
||||||
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsVideoScenes(file services.File) bool {
|
||||||
|
return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsThumbnail(file services.File) bool {
|
||||||
|
return needsThumbnail(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsVideoScenes(file services.File) bool {
|
||||||
|
return needsVideoScenes(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NeedsArchiveListing(file services.File) bool {
|
||||||
|
return needsArchiveListing(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateThumbnail(uploadService, box, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateVideoScenesThumbnail(uploadService, box, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
return generateArchiveListing(uploadService, box, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
@@ -157,11 +228,290 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
|||||||
}
|
}
|
||||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
return thumbnailName, err
|
return thumbnailName, err
|
||||||
|
case isTextThumbnailCandidate(file):
|
||||||
|
data, err := createTextThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return thumbnailName, err
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if !needsVideoScenes(file) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
sceneName := "@scene@" + file.ID + ".jpg"
|
||||||
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
data, err := createVideoScenesThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return sceneName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
|
if !needsArchiveListing(file) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
listingName := "@archive@" + file.ID + ".json"
|
||||||
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer object.Body.Close()
|
||||||
|
|
||||||
|
data, err := createArchiveListing(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
|
||||||
|
return listingName, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTextThumbnailCandidate(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsArchiveListing(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveListingCurrent(file services.File) bool {
|
||||||
|
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveTreeNode struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size uint64 `json:"size,omitempty"`
|
||||||
|
Dir bool `json:"dir"`
|
||||||
|
Icon string `json:"icon,omitempty"`
|
||||||
|
Children map[string]*archiveTreeNode `json:"-"`
|
||||||
|
Items []*archiveTreeNode `json:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type archiveListingData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
FileCount int `json:"fileCount"`
|
||||||
|
FolderCount int `json:"folderCount"`
|
||||||
|
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||||
|
Root *archiveTreeNode `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(sourceFile.Name())
|
||||||
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
|
sourceFile.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
archive, err := zip.OpenReader(sourceFile.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer archive.Close()
|
||||||
|
|
||||||
|
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
|
||||||
|
var totalSize uint64
|
||||||
|
var fileCount int
|
||||||
|
var dirCount int
|
||||||
|
for _, entry := range archive.File {
|
||||||
|
name := strings.Trim(entry.Name, "/")
|
||||||
|
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
node := root
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if node.Children == nil {
|
||||||
|
node.Children = map[string]*archiveTreeNode{}
|
||||||
|
}
|
||||||
|
child, ok := node.Children[part]
|
||||||
|
if !ok {
|
||||||
|
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
|
||||||
|
node.Children[part] = child
|
||||||
|
if child.Dir {
|
||||||
|
dirCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = child
|
||||||
|
}
|
||||||
|
if !entry.FileInfo().IsDir() {
|
||||||
|
node.Dir = false
|
||||||
|
node.Size = entry.UncompressedSize64
|
||||||
|
totalSize += entry.UncompressedSize64
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeArchiveTree(root)
|
||||||
|
data := archiveListingData{
|
||||||
|
Name: file.Name,
|
||||||
|
Type: archiveLabel(file),
|
||||||
|
FileCount: fileCount,
|
||||||
|
FolderCount: dirCount,
|
||||||
|
UncompressedSize: totalSize,
|
||||||
|
Root: root,
|
||||||
|
}
|
||||||
|
return json.MarshalIndent(data, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizeArchiveTree(node *archiveTreeNode) {
|
||||||
|
node.Items = sortedArchiveChildren(node)
|
||||||
|
for _, child := range node.Items {
|
||||||
|
if child.Dir {
|
||||||
|
child.Icon = "folder"
|
||||||
|
finalizeArchiveTree(child)
|
||||||
|
} else {
|
||||||
|
child.Icon = archiveFileIconName(child.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
|
||||||
|
children := sortedArchiveChildren(node)
|
||||||
|
for i, child := range children {
|
||||||
|
last := i == len(children)-1
|
||||||
|
branch := "|-- "
|
||||||
|
nextPrefix := prefix + "| "
|
||||||
|
if last {
|
||||||
|
branch = "`-- "
|
||||||
|
nextPrefix = prefix + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
out.WriteString(prefix)
|
||||||
|
out.WriteString(branch)
|
||||||
|
out.WriteString(archiveNodeLabel(child))
|
||||||
|
out.WriteString("\n")
|
||||||
|
if child.Dir {
|
||||||
|
writeArchiveTree(out, child, nextPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
|
||||||
|
children := make([]*archiveTreeNode, 0, len(node.Children))
|
||||||
|
for _, child := range node.Children {
|
||||||
|
children = append(children, child)
|
||||||
|
}
|
||||||
|
sort.Slice(children, func(i, j int) bool {
|
||||||
|
if children[i].Dir != children[j].Dir {
|
||||||
|
return children[i].Dir
|
||||||
|
}
|
||||||
|
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
|
||||||
|
})
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveNodeLabel(node *archiveTreeNode) string {
|
||||||
|
if node.Dir {
|
||||||
|
return "[DIR] " + node.Name + "/"
|
||||||
|
}
|
||||||
|
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFileIcon(name string) string {
|
||||||
|
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveFileIconName(name string) string {
|
||||||
|
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
|
||||||
|
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
|
||||||
|
return "img"
|
||||||
|
case "mp4", "mov", "webm", "mkv", "avi":
|
||||||
|
return "vid"
|
||||||
|
case "mp3", "wav", "flac", "ogg", "m4a":
|
||||||
|
return "aud"
|
||||||
|
case "md", "txt", "log", "csv":
|
||||||
|
return "txt"
|
||||||
|
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
|
||||||
|
return "code"
|
||||||
|
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||||
|
return "arc"
|
||||||
|
default:
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveLabel(file services.File) string {
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext != "" {
|
||||||
|
return strings.ToUpper(ext) + " archive"
|
||||||
|
}
|
||||||
|
if file.ContentType != "" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "ZIP-compatible archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatArchiveBytes(size uint64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div := float64(unit)
|
||||||
|
value := float64(size) / div
|
||||||
|
units := []string{"KiB", "MiB", "GiB", "TiB"}
|
||||||
|
for _, suffix := range units {
|
||||||
|
if value < unit {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||||
|
}
|
||||||
|
value /= div
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f PiB", value)
|
||||||
|
}
|
||||||
|
|
||||||
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||||
img, _, err := image.Decode(source)
|
img, _, err := image.Decode(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -190,17 +540,511 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
|||||||
if err := sourceFile.Close(); err != nil {
|
if err := sourceFile.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
sourcePath := sourceFile.Name()
|
||||||
|
candidates := []string{"00:00:01", "00:00:03", "00:00:06"}
|
||||||
|
var fallback []byte
|
||||||
|
for _, timestamp := range candidates {
|
||||||
|
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
targetPath := targetFile.Name()
|
||||||
|
targetFile.Close()
|
||||||
|
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
|
||||||
|
os.Remove(targetPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
os.Remove(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(fallback) == 0 {
|
||||||
|
fallback = data
|
||||||
|
}
|
||||||
|
if usableVideoFrame(data) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath)
|
||||||
|
if err == nil {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(scenes))
|
||||||
|
if err == nil {
|
||||||
|
thumb := resizeNearest(img, 360, 240)
|
||||||
|
var target bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil {
|
||||||
|
return target.Bytes(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(fallback) > 0 {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("could not extract a usable video thumbnail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
targetPath := targetFile.Name()
|
defer os.Remove(sourceFile.Name())
|
||||||
targetFile.Close()
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
defer os.Remove(targetPath)
|
sourceFile.Close()
|
||||||
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return os.ReadFile(targetPath)
|
if err := sourceFile.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return createVideoScenesThumbnailFromPath(file, sourceFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) {
|
||||||
|
info := probeVideoInfo(sourcePath, file)
|
||||||
|
timestamps := videoSceneTimestamps(info.Duration)
|
||||||
|
frames := make([]videoSceneFrame, 0, len(timestamps))
|
||||||
|
|
||||||
|
for _, timestamp := range timestamps {
|
||||||
|
targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targetPath := targetFile.Name()
|
||||||
|
targetFile.Close()
|
||||||
|
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil {
|
||||||
|
os.Remove(targetPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(targetPath)
|
||||||
|
os.Remove(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img})
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderVideoScenesThumbnail(file, info, frames), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error {
|
||||||
|
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoSceneFrame struct {
|
||||||
|
Timestamp string
|
||||||
|
Image image.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
type videoInfo struct {
|
||||||
|
Codec string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Duration float64
|
||||||
|
FrameRate string
|
||||||
|
}
|
||||||
|
|
||||||
|
func probeVideoInfo(sourcePath string, file services.File) videoInfo {
|
||||||
|
info := videoInfo{Codec: "unknown", FrameRate: "unknown"}
|
||||||
|
output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output()
|
||||||
|
if err != nil {
|
||||||
|
if file.ContentType != "" {
|
||||||
|
info.Codec = file.ContentType
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||||
|
if !ok || value == "" || value == "N/A" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "codec_name":
|
||||||
|
info.Codec = value
|
||||||
|
case "width":
|
||||||
|
info.Width, _ = strconv.Atoi(value)
|
||||||
|
case "height":
|
||||||
|
info.Height, _ = strconv.Atoi(value)
|
||||||
|
case "duration":
|
||||||
|
info.Duration, _ = strconv.ParseFloat(value, 64)
|
||||||
|
case "avg_frame_rate":
|
||||||
|
info.FrameRate = simplifyFrameRate(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
func simplifyFrameRate(value string) string {
|
||||||
|
if value == "0/0" || value == "" {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
n, errN := strconv.ParseFloat(parts[0], 64)
|
||||||
|
d, errD := strconv.ParseFloat(parts[1], 64)
|
||||||
|
if errN != nil || errD != nil || d == 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f fps", n/d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoSceneTimestamps(duration float64) []string {
|
||||||
|
if duration > 4 {
|
||||||
|
points := []float64{0.12, 0.33, 0.58, 0.82}
|
||||||
|
timestamps := make([]string, 0, len(points))
|
||||||
|
for _, point := range points {
|
||||||
|
seconds := duration * point
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
timestamps = append(timestamps, secondsToTimestamp(seconds))
|
||||||
|
}
|
||||||
|
return timestamps
|
||||||
|
}
|
||||||
|
return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsToTimestamp(seconds float64) string {
|
||||||
|
total := int(seconds + 0.5)
|
||||||
|
hours := total / 3600
|
||||||
|
minutes := total % 3600 / 60
|
||||||
|
secs := total % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usableVideoFrame(data []byte) bool {
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return averageLuma(img) >= 18
|
||||||
|
}
|
||||||
|
|
||||||
|
func averageLuma(img image.Image) float64 {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
width := bounds.Dx()
|
||||||
|
height := bounds.Dy()
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
stepX := max(1, width/80)
|
||||||
|
stepY := max(1, height/80)
|
||||||
|
var total float64
|
||||||
|
var samples int
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY {
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x += stepX {
|
||||||
|
r, g, b, _ := img.At(x, y).RGBA()
|
||||||
|
total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8)
|
||||||
|
samples++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if samples == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return total / float64(samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff})
|
||||||
|
|
||||||
|
meta := videoMetaLines(file, info)
|
||||||
|
y := 122
|
||||||
|
for _, line := range meta {
|
||||||
|
drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff})
|
||||||
|
y += 20
|
||||||
|
}
|
||||||
|
|
||||||
|
cells := []image.Rectangle{
|
||||||
|
image.Rect(62, 212, 586, 388),
|
||||||
|
image.Rect(614, 212, 1138, 388),
|
||||||
|
image.Rect(62, 414, 586, 566),
|
||||||
|
image.Rect(614, 414, 1138, 566),
|
||||||
|
}
|
||||||
|
for i, rect := range cells {
|
||||||
|
drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff})
|
||||||
|
if i < len(frames) {
|
||||||
|
drawImageCover(canvas, rect, frames[i].Image)
|
||||||
|
drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc})
|
||||||
|
drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func videoMetaLines(file services.File, info videoInfo) []string {
|
||||||
|
resolution := "unknown resolution"
|
||||||
|
if info.Width > 0 && info.Height > 0 {
|
||||||
|
resolution = fmt.Sprintf("%dx%d", info.Width, info.Height)
|
||||||
|
}
|
||||||
|
duration := "unknown duration"
|
||||||
|
if info.Duration > 0 {
|
||||||
|
duration = secondsToHumanDuration(info.Duration)
|
||||||
|
}
|
||||||
|
contentType := file.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "video"
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
"Duration: " + duration + " Codec: " + info.Codec,
|
||||||
|
"Resolution: " + resolution + " Frame rate: " + info.FrameRate,
|
||||||
|
"Type: " + contentType + " Generated by Warpbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func secondsToHumanDuration(seconds float64) string {
|
||||||
|
total := int(seconds + 0.5)
|
||||||
|
hours := total / 3600
|
||||||
|
minutes := total % 3600 / 60
|
||||||
|
secs := total % 60
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) {
|
||||||
|
bounds := src.Bounds()
|
||||||
|
srcW := bounds.Dx()
|
||||||
|
srcH := bounds.Dy()
|
||||||
|
dstW := rect.Dx()
|
||||||
|
dstH := rect.Dy()
|
||||||
|
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srcRatio := float64(srcW) / float64(srcH)
|
||||||
|
dstRatio := float64(dstW) / float64(dstH)
|
||||||
|
crop := bounds
|
||||||
|
if srcRatio > dstRatio {
|
||||||
|
newW := int(float64(srcH) * dstRatio)
|
||||||
|
x0 := bounds.Min.X + (srcW-newW)/2
|
||||||
|
crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y)
|
||||||
|
} else if srcRatio < dstRatio {
|
||||||
|
newH := int(float64(srcW) / dstRatio)
|
||||||
|
y0 := bounds.Min.Y + (srcH-newH)/2
|
||||||
|
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH)
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||||
|
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||||
|
u := float64(x-rect.Min.X) / float64(dstW)
|
||||||
|
v := float64(y-rect.Min.Y) / float64(dstH)
|
||||||
|
srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx())))
|
||||||
|
srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy())))
|
||||||
|
dst.Set(x, y, src.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||||
|
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
|
||||||
|
|
||||||
|
mode := textThumbnailMode(file)
|
||||||
|
title := strings.ToUpper(mode)
|
||||||
|
var lines []string
|
||||||
|
if mode == "HTML" {
|
||||||
|
lines = renderedHTMLThumbnailLines(sourceText)
|
||||||
|
} else if mode == "MARKDOWN" {
|
||||||
|
lines = renderedMarkdownThumbnailLines(sourceText)
|
||||||
|
} else {
|
||||||
|
title = "CODE"
|
||||||
|
lines = codeThumbnailLines(sourceText)
|
||||||
|
}
|
||||||
|
return renderTextThumbnail(file.Name, title, lines), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textThumbnailMode(file services.File) string {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext == "html" || ext == "htm" || contentType == "text/html" {
|
||||||
|
return "HTML"
|
||||||
|
}
|
||||||
|
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
|
||||||
|
return "MARKDOWN"
|
||||||
|
}
|
||||||
|
return "CODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedHTMLThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
|
||||||
|
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
|
||||||
|
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
|
||||||
|
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
|
||||||
|
text = html.UnescapeString(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedMarkdownThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
|
||||||
|
block = strings.Trim(block, "` \n\t")
|
||||||
|
lines := strings.Split(block, "\n")
|
||||||
|
if len(lines) > 1 {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
return "\n" + strings.Join(lines, "\n") + "\n"
|
||||||
|
})
|
||||||
|
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
||||||
|
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentThumbnailLines(source string) []string {
|
||||||
|
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 9)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range wrapTextThumbnailLine(raw, 43) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
if len(lines) >= 9 {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"Rendered preview is empty."}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func codeThumbnailLines(source string) []string {
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 10)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
|
raw = strings.TrimRight(raw, " ")
|
||||||
|
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) > 48 {
|
||||||
|
raw = raw[:45] + "..."
|
||||||
|
}
|
||||||
|
lines = append(lines, raw)
|
||||||
|
if len(lines) >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"(empty file)"}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextThumbnail(name, mode string, lines []string) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
|
||||||
|
|
||||||
|
codePane := image.Rect(22, 72, 338, 210)
|
||||||
|
if mode == "CODE" {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 91
|
||||||
|
for _, line := range lines {
|
||||||
|
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
|
||||||
|
y += 14
|
||||||
|
if y > 202 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
|
||||||
|
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapTextThumbnailLine(text string, maxChars int) []string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text[:maxChars-3] + "..."}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := ""
|
||||||
|
for _, word := range words {
|
||||||
|
if current == "" {
|
||||||
|
current = word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(current)+1+len(word) <= maxChars {
|
||||||
|
current += " " + word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimThumbnailText(text string, maxChars int) string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if maxChars <= 3 {
|
||||||
|
return text[:maxChars]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text[:maxChars-3]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -46,6 +50,121 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
166
backend/libs/services/reactions.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var reactionsBucket = []byte("file_reactions")
|
||||||
|
|
||||||
|
type ReactionService struct {
|
||||||
|
db *bbolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileReaction struct {
|
||||||
|
BoxID string `json:"boxId"`
|
||||||
|
FileID string `json:"fileId"`
|
||||||
|
EmojiID string `json:"emojiId"`
|
||||||
|
VisitorHash string `json:"visitorHash"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactionSummary struct {
|
||||||
|
EmojiID string `json:"emojiId"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReactionService(db *bbolt.DB) (*ReactionService, error) {
|
||||||
|
if err := db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists(reactionsBucket)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ReactionService{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionService) Add(boxID, fileID, visitorID, emojiID string) ([]ReactionSummary, error) {
|
||||||
|
boxID = strings.TrimSpace(boxID)
|
||||||
|
fileID = strings.TrimSpace(fileID)
|
||||||
|
visitorHash := reactionVisitorHash(visitorID)
|
||||||
|
emojiID = strings.TrimSpace(emojiID)
|
||||||
|
if boxID == "" || fileID == "" || visitorHash == "" || emojiID == "" {
|
||||||
|
return nil, errors.New("missing reaction data")
|
||||||
|
}
|
||||||
|
|
||||||
|
reaction := FileReaction{
|
||||||
|
BoxID: boxID,
|
||||||
|
FileID: fileID,
|
||||||
|
EmojiID: emojiID,
|
||||||
|
VisitorHash: visitorHash,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(reaction)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key := reactionKey(boxID, fileID, visitorHash)
|
||||||
|
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(reactionsBucket)
|
||||||
|
if bucket.Get([]byte(key)) != nil {
|
||||||
|
return os.ErrExist
|
||||||
|
}
|
||||||
|
return bucket.Put([]byte(key), data)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.SummaryForFile(boxID, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionService) SummaryForBox(boxID, visitorID string) (map[string][]ReactionSummary, map[string]bool, error) {
|
||||||
|
visitorHash := reactionVisitorHash(visitorID)
|
||||||
|
summaries := make(map[string]map[string]int)
|
||||||
|
viewerReacted := make(map[string]bool)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(reactionsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.ForEach(func(_, data []byte) error {
|
||||||
|
var reaction FileReaction
|
||||||
|
if err := json.Unmarshal(data, &reaction); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reaction.BoxID != boxID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if summaries[reaction.FileID] == nil {
|
||||||
|
summaries[reaction.FileID] = make(map[string]int)
|
||||||
|
}
|
||||||
|
summaries[reaction.FileID][reaction.EmojiID]++
|
||||||
|
if visitorHash != "" && reaction.VisitorHash == visitorHash {
|
||||||
|
viewerReacted[reaction.FileID] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string][]ReactionSummary, len(summaries))
|
||||||
|
for fileID, counts := range summaries {
|
||||||
|
result[fileID] = reactionCountsToSummaries(counts)
|
||||||
|
}
|
||||||
|
return result, viewerReacted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionService) SummaryForFile(boxID, fileID string) ([]ReactionSummary, error) {
|
||||||
|
counts := make(map[string]int)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(reactionsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.ForEach(func(_, data []byte) error {
|
||||||
|
var reaction FileReaction
|
||||||
|
if err := json.Unmarshal(data, &reaction); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reaction.BoxID == boxID && reaction.FileID == fileID {
|
||||||
|
counts[reaction.EmojiID]++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reactionCountsToSummaries(counts), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionCountsToSummaries(counts map[string]int) []ReactionSummary {
|
||||||
|
summaries := make([]ReactionSummary, 0, len(counts))
|
||||||
|
for emojiID, count := range counts {
|
||||||
|
summaries = append(summaries, ReactionSummary{EmojiID: emojiID, Count: count})
|
||||||
|
}
|
||||||
|
sort.Slice(summaries, func(i, j int) bool {
|
||||||
|
if summaries[i].Count == summaries[j].Count {
|
||||||
|
return summaries[i].EmojiID < summaries[j].EmojiID
|
||||||
|
}
|
||||||
|
return summaries[i].Count > summaries[j].Count
|
||||||
|
})
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionKey(boxID, fileID, visitorHash string) string {
|
||||||
|
return boxID + "\x00" + fileID + "\x00" + visitorHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func reactionVisitorHash(visitorID string) string {
|
||||||
|
visitorID = strings.TrimSpace(visitorID)
|
||||||
|
if visitorID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(visitorID))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
735
backend/libs/services/resumable.go
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var resumableUploadsBucket = []byte("resumable_uploads")
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResumableStatusUploading = "uploading"
|
||||||
|
ResumableStatusProcessing = "processing"
|
||||||
|
ResumableStatusCompleted = "completed"
|
||||||
|
ResumableStatusCancelled = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ResumableFileInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResumableSession struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Options UploadOptions `json:"options"`
|
||||||
|
Files []ResumableFile `json:"files"`
|
||||||
|
ChunkSize int64 `json:"chunkSize"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
BoxID string `json:"boxId,omitempty"`
|
||||||
|
ResumeTokenHash string `json:"resumeTokenHash,omitempty"`
|
||||||
|
ResumeToken string `json:"-"`
|
||||||
|
ChunkRoot string `json:"chunkRoot,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResumableFile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
ChunkCount int `json:"chunkCount"`
|
||||||
|
UploadedChunks []int `json:"uploadedChunks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) ensureResumableBucket() error {
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
_, err := tx.CreateBucketIfNotExists(resumableUploadsBucket)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CreateResumableSession(files []ResumableFileInput, opts UploadOptions, chunkSize int64, retention time.Duration, chunkRoot string) (ResumableSession, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return ResumableSession{}, fmt.Errorf("no files were uploaded")
|
||||||
|
}
|
||||||
|
if chunkSize <= 0 {
|
||||||
|
return ResumableSession{}, fmt.Errorf("chunk size must be positive")
|
||||||
|
}
|
||||||
|
if retention <= 0 {
|
||||||
|
return ResumableSession{}, fmt.Errorf("retention must be positive")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(opts.Password) != "" {
|
||||||
|
opts.PasswordSalt, opts.PasswordHash = hashPassword(opts.Password)
|
||||||
|
opts.Password = ""
|
||||||
|
}
|
||||||
|
sessionFiles, err := s.resumableFilesFromInput(files, opts, chunkSize, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
resumeToken := randomID(32)
|
||||||
|
sessionID := randomID(12)
|
||||||
|
session := ResumableSession{
|
||||||
|
ID: sessionID,
|
||||||
|
Options: opts,
|
||||||
|
Files: sessionFiles,
|
||||||
|
ChunkSize: chunkSize,
|
||||||
|
Status: ResumableStatusUploading,
|
||||||
|
ResumeTokenHash: resumableTokenHash(sessionID, resumeToken),
|
||||||
|
ResumeToken: resumeToken,
|
||||||
|
ChunkRoot: strings.TrimSpace(chunkRoot),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ExpiresAt: now.Add(retention),
|
||||||
|
}
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) VerifyResumableToken(session ResumableSession, token string) bool {
|
||||||
|
if session.ResumeTokenHash == "" || strings.TrimSpace(token) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hash := resumableTokenHash(session.ID, token)
|
||||||
|
return subtle.ConstantTimeCompare([]byte(hash), []byte(session.ResumeTokenHash)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) AddResumableFiles(sessionID string, files []ResumableFileInput) (ResumableSession, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return s.GetResumableSession(sessionID)
|
||||||
|
}
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
existing := make(map[string]bool)
|
||||||
|
for _, file := range session.Files {
|
||||||
|
existing[resumableFileKey(file.Name, file.Size, file.Fingerprint)] = true
|
||||||
|
}
|
||||||
|
newFiles, err := s.resumableFilesFromInput(files, session.Options, session.ChunkSize, existing)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if len(newFiles) == 0 {
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
session.Files = append(session.Files, newFiles...)
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) GetResumableSession(id string) (ResumableSession, error) {
|
||||||
|
var session ResumableSession
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
data := bucket.Get([]byte(id))
|
||||||
|
if data == nil {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &session)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) PutResumableChunk(ctx context.Context, sessionID, fileID string, index int, body io.Reader) (ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
fileIndex := -1
|
||||||
|
for i, file := range session.Files {
|
||||||
|
if file.ID == fileID {
|
||||||
|
fileIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fileIndex < 0 {
|
||||||
|
return ResumableSession{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
file := session.Files[fileIndex]
|
||||||
|
if index < 0 || index >= file.ChunkCount {
|
||||||
|
return ResumableSession{}, fmt.Errorf("chunk index is invalid")
|
||||||
|
}
|
||||||
|
expectedSize := expectedChunkSize(file.Size, session.ChunkSize, index)
|
||||||
|
chunkDir := s.resumableFileDirFor(session, file.ID)
|
||||||
|
if err := os.MkdirAll(chunkDir, 0o755); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
chunkPath := s.resumableChunkPathFor(session, file.ID, index)
|
||||||
|
tempPath := chunkPath + ".tmp"
|
||||||
|
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
written, copyErr := io.Copy(target, io.LimitReader(body, expectedSize+1))
|
||||||
|
closeErr := target.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, closeErr
|
||||||
|
}
|
||||||
|
if written != expectedSize {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, fmt.Errorf("chunk size mismatch")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tempPath, chunkPath); err != nil {
|
||||||
|
_ = os.Remove(tempPath)
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Files[fileIndex].UploadedChunks = addChunkIndex(session.Files[fileIndex].UploadedChunks, index)
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CompleteResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if (session.Status == ResumableStatusCompleted || session.Status == ResumableStatusProcessing) && session.BoxID != "" {
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), session, nil
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
staged, err := s.resumableIncomingFiles(session)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.CreateBoxFromIncomingContext(ctx, staged, session.Options)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusCompleted
|
||||||
|
session.BoxID = result.BoxID
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return result, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (UploadResult, ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if (session.Status == ResumableStatusCompleted || session.Status == ResumableStatusProcessing) && session.BoxID != "" {
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), session, nil
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.resumableIncomingFiles(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expiresAt := now.AddDate(0, 0, 7)
|
||||||
|
if session.Options.ExpiresInMinutes < 0 || session.Options.MaxDays < 0 {
|
||||||
|
expiresAt = now.AddDate(100, 0, 0)
|
||||||
|
} else if session.Options.ExpiresInMinutes > 0 {
|
||||||
|
expiresAt = now.Add(time.Duration(session.Options.ExpiresInMinutes) * time.Minute)
|
||||||
|
} else if session.Options.MaxDays > 0 {
|
||||||
|
expiresAt = now.Add(time.Duration(session.Options.MaxDays) * 24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
box := Box{
|
||||||
|
ID: randomID(10),
|
||||||
|
OwnerID: strings.TrimSpace(session.Options.OwnerID),
|
||||||
|
CollectionID: strings.TrimSpace(session.Options.CollectionID),
|
||||||
|
CreatorIP: strings.TrimSpace(session.Options.CreatorIP),
|
||||||
|
StorageBackendID: normalizeBackendID(session.Options.StorageBackendID),
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
MaxDownloads: session.Options.MaxDownloads,
|
||||||
|
Obfuscate: session.Options.ObfuscateMetadata && (strings.TrimSpace(session.Options.Password) != "" || strings.TrimSpace(session.Options.PasswordHash) != ""),
|
||||||
|
Files: make([]File, 0, len(session.Files)),
|
||||||
|
}
|
||||||
|
deleteToken := randomID(32)
|
||||||
|
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
|
||||||
|
if strings.TrimSpace(session.Options.PasswordHash) != "" {
|
||||||
|
box.PasswordSalt = session.Options.PasswordSalt
|
||||||
|
box.PasswordHash = session.Options.PasswordHash
|
||||||
|
} else if strings.TrimSpace(session.Options.Password) != "" {
|
||||||
|
salt, hash := hashPassword(session.Options.Password)
|
||||||
|
box.PasswordSalt = salt
|
||||||
|
box.PasswordHash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, incoming := range session.Files {
|
||||||
|
fileID := randomID(8)
|
||||||
|
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name))
|
||||||
|
objectKey := boxObjectKey(box.ID, storedName)
|
||||||
|
contentType := incoming.ContentType
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
box.Files = append(box.Files, File{
|
||||||
|
ID: fileID,
|
||||||
|
Name: filepath.Base(incoming.Name),
|
||||||
|
StoredName: storedName,
|
||||||
|
Size: incoming.Size,
|
||||||
|
ContentType: contentType,
|
||||||
|
PreviewKind: previewKind(contentType),
|
||||||
|
ObjectKey: objectKey,
|
||||||
|
Processing: true,
|
||||||
|
UploadedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.saveBoxRecord(box); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusProcessing
|
||||||
|
session.BoxID = box.ID
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.saveResumableSession(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, deleteToken), session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context, sessionID string) (UploadResult, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if session.Status == ResumableStatusCompleted && session.BoxID != "" {
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
return s.resultForBox(box, ""), nil
|
||||||
|
}
|
||||||
|
if session.Status != ResumableStatusProcessing || session.BoxID == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("upload session is not processing")
|
||||||
|
}
|
||||||
|
box, err := s.GetBox(session.BoxID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
staged, err := s.resumableIncomingFiles(session)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if len(staged) != len(box.Files) {
|
||||||
|
return UploadResult{}, fmt.Errorf("processing file count mismatch")
|
||||||
|
}
|
||||||
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
for i, incoming := range staged {
|
||||||
|
source, err := incoming.Open()
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
box.Files[i].ProcessingError = err.Error()
|
||||||
|
_ = s.saveBoxRecord(box)
|
||||||
|
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) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := resumableSessionWritable(session); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
completeFiles := make([]ResumableFile, 0, len(session.Files))
|
||||||
|
for _, file := range session.Files {
|
||||||
|
if resumableFileComplete(file) {
|
||||||
|
completeFiles = append(completeFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(completeFiles) == 0 {
|
||||||
|
return UploadResult{}, ResumableSession{}, fmt.Errorf("no fully uploaded files to finish")
|
||||||
|
}
|
||||||
|
partial := session
|
||||||
|
partial.Files = completeFiles
|
||||||
|
staged, err := s.resumableIncomingFiles(partial)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.CreateBoxFromIncomingContext(ctx, staged, session.Options)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
session.Status = ResumableStatusCompleted
|
||||||
|
session.BoxID = result.BoxID
|
||||||
|
session.Files = completeFiles
|
||||||
|
session.UpdatedAt = time.Now().UTC()
|
||||||
|
if err := s.deleteResumableSession(session.ID); err != nil {
|
||||||
|
return UploadResult{}, ResumableSession{}, err
|
||||||
|
}
|
||||||
|
return result, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CancelResumableSession(sessionID string) error {
|
||||||
|
session, err := s.GetResumableSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.deleteResumableSession(session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) CleanupExpiredResumableSessions(now time.Time) (int, error) {
|
||||||
|
candidates := make([]ResumableSession, 0)
|
||||||
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.ForEach(func(_, value []byte) error {
|
||||||
|
var session ResumableSession
|
||||||
|
if err := json.Unmarshal(value, &session); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if session.Status == ResumableStatusCompleted ||
|
||||||
|
session.Status == ResumableStatusCancelled ||
|
||||||
|
(session.Status == ResumableStatusUploading && !session.ExpiresAt.After(now)) {
|
||||||
|
candidates = append(candidates, session)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, session := range candidates {
|
||||||
|
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, session := range candidates {
|
||||||
|
if err := bucket.Delete([]byte(session.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return len(candidates), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) deleteResumableSession(sessionID string) error {
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(resumableUploadsBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bucket.Delete([]byte(sessionID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) saveResumableSession(session ResumableSession) error {
|
||||||
|
if err := s.ensureResumableBucket(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||||
|
data, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Bucket(resumableUploadsBucket).Put([]byte(session.ID), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
|
||||||
|
sessionFiles := make([]ResumableFile, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
file.Name = filepath.Base(strings.TrimSpace(file.Name))
|
||||||
|
if file.Name == "." || file.Name == "" {
|
||||||
|
return nil, fmt.Errorf("file name is required")
|
||||||
|
}
|
||||||
|
if file.Size < 0 {
|
||||||
|
return nil, fmt.Errorf("file size is invalid")
|
||||||
|
}
|
||||||
|
fingerprint := strings.TrimSpace(file.Fingerprint)
|
||||||
|
key := resumableFileKey(file.Name, file.Size, fingerprint)
|
||||||
|
if existing != nil && existing[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !opts.SkipSizeLimit {
|
||||||
|
if err := s.ValidateSize(file.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks := int((file.Size + chunkSize - 1) / chunkSize)
|
||||||
|
if chunks == 0 {
|
||||||
|
chunks = 1
|
||||||
|
}
|
||||||
|
sessionFiles = append(sessionFiles, ResumableFile{
|
||||||
|
ID: randomID(8),
|
||||||
|
Name: file.Name,
|
||||||
|
Size: file.Size,
|
||||||
|
ContentType: strings.TrimSpace(file.ContentType),
|
||||||
|
Fingerprint: fingerprint,
|
||||||
|
ChunkCount: chunks,
|
||||||
|
})
|
||||||
|
if existing != nil {
|
||||||
|
existing[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessionFiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableFileKey(name string, size int64, fingerprint string) string {
|
||||||
|
return strings.TrimSpace(fingerprint) + "|" + filepath.Base(strings.TrimSpace(name)) + "|" + fmt.Sprintf("%d", size)
|
||||||
|
}
|
||||||
|
|
||||||
|
type resumableIncomingFile struct {
|
||||||
|
service *UploadService
|
||||||
|
session ResumableSession
|
||||||
|
file ResumableFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) Name() string {
|
||||||
|
return f.file.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) Size() int64 {
|
||||||
|
return f.file.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) ContentType() string {
|
||||||
|
return f.file.ContentType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f resumableIncomingFile) Open() (io.ReadCloser, error) {
|
||||||
|
return &resumableChunkReader{
|
||||||
|
service: f.service,
|
||||||
|
session: f.session,
|
||||||
|
file: f.file,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resumableChunkReader struct {
|
||||||
|
service *UploadService
|
||||||
|
session ResumableSession
|
||||||
|
file ResumableFile
|
||||||
|
index int
|
||||||
|
current *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *resumableChunkReader) Read(p []byte) (int, error) {
|
||||||
|
for {
|
||||||
|
if r.current == nil {
|
||||||
|
if r.index >= r.file.ChunkCount {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
chunk, err := os.Open(r.service.resumableChunkPathFor(r.session, r.file.ID, r.index))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
r.current = chunk
|
||||||
|
}
|
||||||
|
n, err := r.current.Read(p)
|
||||||
|
if err == io.EOF {
|
||||||
|
if closeErr := r.current.Close(); closeErr != nil {
|
||||||
|
r.current = nil
|
||||||
|
return n, closeErr
|
||||||
|
}
|
||||||
|
r.current = nil
|
||||||
|
r.index++
|
||||||
|
if n > 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *resumableChunkReader) Close() error {
|
||||||
|
if r.current == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := r.current.Close()
|
||||||
|
r.current = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableIncomingFiles(session ResumableSession) ([]IncomingFile, error) {
|
||||||
|
staged := make([]IncomingFile, 0, len(session.Files))
|
||||||
|
for _, file := range session.Files {
|
||||||
|
if len(file.UploadedChunks) != file.ChunkCount {
|
||||||
|
return nil, fmt.Errorf("file %s is missing chunks", file.Name)
|
||||||
|
}
|
||||||
|
var written int64
|
||||||
|
for i := 0; i < file.ChunkCount; i++ {
|
||||||
|
info, err := os.Stat(s.resumableChunkPathFor(session, file.ID, i))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("file %s is missing chunks", file.Name)
|
||||||
|
}
|
||||||
|
written += info.Size()
|
||||||
|
}
|
||||||
|
if written != file.Size {
|
||||||
|
return nil, fmt.Errorf("chunk size total mismatch")
|
||||||
|
}
|
||||||
|
staged = append(staged, resumableIncomingFile{
|
||||||
|
service: s,
|
||||||
|
session: session,
|
||||||
|
file: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return staged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableSessionWritable(session ResumableSession) error {
|
||||||
|
if session.Status != ResumableStatusUploading {
|
||||||
|
return fmt.Errorf("upload session is not active")
|
||||||
|
}
|
||||||
|
if !session.ExpiresAt.After(time.Now().UTC()) {
|
||||||
|
return fmt.Errorf("upload session expired")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableFileComplete(file ResumableFile) bool {
|
||||||
|
return file.ChunkCount > 0 && len(file.UploadedChunks) == file.ChunkCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectedChunkSize(fileSize, chunkSize int64, index int) int64 {
|
||||||
|
offset := int64(index) * chunkSize
|
||||||
|
remaining := fileSize - offset
|
||||||
|
if remaining < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if remaining > chunkSize {
|
||||||
|
return chunkSize
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
func addChunkIndex(chunks []int, index int) []int {
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
if chunk == index {
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunks = append(chunks, index)
|
||||||
|
sort.Ints(chunks)
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func resumableTokenHash(sessionID, token string) string {
|
||||||
|
sum := sha256.Sum256([]byte("warpbox-resumable:" + sessionID + ":" + token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableSessionDir(sessionID string) string {
|
||||||
|
return filepath.Join(s.dataDir, "tmp", "uploads", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableSessionDirFor(session ResumableSession) string {
|
||||||
|
if strings.TrimSpace(session.ChunkRoot) != "" {
|
||||||
|
return filepath.Join(session.ChunkRoot, session.ID)
|
||||||
|
}
|
||||||
|
return s.resumableSessionDir(session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableFileDir(sessionID, fileID string) string {
|
||||||
|
return filepath.Join(s.resumableSessionDir(sessionID), fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableFileDirFor(session ResumableSession, fileID string) string {
|
||||||
|
return filepath.Join(s.resumableSessionDirFor(session), fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableChunkPath(sessionID, fileID string, index int) string {
|
||||||
|
return filepath.Join(s.resumableFileDir(sessionID, fileID), fmt.Sprintf("%06d.part", index))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UploadService) resumableChunkPathFor(session ResumableSession, fileID string, index int) string {
|
||||||
|
return filepath.Join(s.resumableFileDirFor(session, fileID), fmt.Sprintf("%06d.part", index))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ type UploadOptions struct {
|
|||||||
ExpiresInMinutes int
|
ExpiresInMinutes int
|
||||||
MaxDownloads int
|
MaxDownloads int
|
||||||
Password string
|
Password string
|
||||||
|
PasswordSalt string
|
||||||
|
PasswordHash string
|
||||||
ObfuscateMetadata bool
|
ObfuscateMetadata bool
|
||||||
OwnerID string
|
OwnerID string
|
||||||
CollectionID string
|
CollectionID string
|
||||||
@@ -50,6 +52,56 @@ type UploadOptions struct {
|
|||||||
StorageBackendID string
|
StorageBackendID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IncomingFile interface {
|
||||||
|
Name() string
|
||||||
|
Size() int64
|
||||||
|
ContentType() string
|
||||||
|
Open() (io.ReadCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type multipartIncomingFile struct {
|
||||||
|
header *multipart.FileHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) Name() string {
|
||||||
|
return f.header.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) Size() int64 {
|
||||||
|
return f.header.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) ContentType() string {
|
||||||
|
return f.header.Header.Get("Content-Type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f multipartIncomingFile) Open() (io.ReadCloser, error) {
|
||||||
|
return f.header.Open()
|
||||||
|
}
|
||||||
|
|
||||||
|
type StagedUploadFile struct {
|
||||||
|
Filename string
|
||||||
|
FileSize int64
|
||||||
|
MIMEType string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) Name() string {
|
||||||
|
return f.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) Size() int64 {
|
||||||
|
return f.FileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) ContentType() string {
|
||||||
|
return f.MIMEType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f StagedUploadFile) Open() (io.ReadCloser, error) {
|
||||||
|
return os.Open(f.Path)
|
||||||
|
}
|
||||||
|
|
||||||
type Box struct {
|
type Box struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
OwnerID string `json:"ownerId,omitempty"`
|
OwnerID string `json:"ownerId,omitempty"`
|
||||||
@@ -69,16 +121,22 @@ type Box struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
StoredName string `json:"storedName"`
|
StoredName string `json:"storedName"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
PreviewKind string `json:"previewKind"`
|
PreviewKind string `json:"previewKind"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
ObjectKey string `json:"objectKey,omitempty"`
|
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
ArchiveListing string `json:"archiveListing,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploadedAt"`
|
ObjectKey string `json:"objectKey,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
@@ -98,6 +156,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 {
|
||||||
@@ -137,6 +196,9 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
|
|||||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
|
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,6 +257,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")
|
||||||
}
|
}
|
||||||
@@ -229,13 +299,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +331,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")
|
||||||
}
|
}
|
||||||
@@ -265,7 +342,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 {
|
||||||
@@ -286,14 +363,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,16 +392,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])
|
||||||
@@ -321,17 +410,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: filepath.Base(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,
|
||||||
@@ -645,6 +735,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:]...)
|
||||||
@@ -732,7 +828,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
|
||||||
@@ -752,6 +871,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 {
|
||||||
@@ -854,6 +997,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
|
||||||
}
|
}
|
||||||
@@ -863,10 +1013,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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,6 +1026,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,21 +1076,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 {
|
||||||
@@ -957,6 +1118,10 @@ func randomID(byteCount int) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(data)
|
return base64.RawURLEncoding.EncodeToString(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RandomPublicToken(byteCount int) string {
|
||||||
|
return randomID(byteCount)
|
||||||
|
}
|
||||||
|
|
||||||
func hashPassword(password string) (string, string) {
|
func hashPassword(password string) (string, string) {
|
||||||
salt := randomID(18)
|
salt := randomID(18)
|
||||||
return salt, passwordHash(salt, password)
|
return salt, passwordHash(salt, password)
|
||||||
|
|||||||
@@ -126,6 +126,262 @@ 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 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
|
||||||
@@ -15,16 +18,23 @@ type Renderer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
AppName string
|
AppName string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Title string
|
CanonicalURL string
|
||||||
Description string
|
Robots string
|
||||||
ImageURL string
|
OGType string
|
||||||
CurrentYear int
|
Title string
|
||||||
CurrentUser any
|
Description string
|
||||||
CSRFToken string
|
ImageURL string
|
||||||
Data any
|
ImageAlt string
|
||||||
|
ImageType string
|
||||||
|
MediaURL string
|
||||||
|
MediaType string
|
||||||
|
CurrentYear int
|
||||||
|
CurrentUser any
|
||||||
|
CSRFToken string
|
||||||
|
Data any
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRenderer(templateDir, appName, appVersion, baseURL string) (*Renderer, error) {
|
func NewRenderer(templateDir, appName, appVersion, baseURL string) (*Renderer, error) {
|
||||||
|
|||||||
BIN
backend/static/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
backend/static/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
backend/static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -592,31 +592,152 @@
|
|||||||
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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-copy {
|
.hero-copy {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -335,10 +343,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 +360,81 @@ 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-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,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -95,6 +95,41 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-browser-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-toolbar,
|
||||||
|
.file-browser-toolbar .view-toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-toolbar .view-toolbar .button,
|
||||||
|
.file-browser-toolbar > .button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-toolbar .view-toolbar .icon-button {
|
||||||
|
flex: 0 0 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-open {
|
||||||
|
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-browser.is-list .file-card {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(7rem, auto);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.65rem;
|
font-size: 1.65rem;
|
||||||
}
|
}
|
||||||
@@ -213,6 +248,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%;
|
||||||
@@ -220,6 +303,16 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-reaction-dock {
|
||||||
|
right: 0.5rem;
|
||||||
|
bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-button {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.file-progress-side {
|
.file-progress-side {
|
||||||
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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
304
backend/static/js/12-reactions.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
(function () {
|
||||||
|
const picker = document.querySelector("[data-reaction-picker]");
|
||||||
|
const panel = picker ? picker.querySelector(".reaction-picker-panel") : null;
|
||||||
|
const search = picker ? picker.querySelector("[data-reaction-search]") : null;
|
||||||
|
const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null;
|
||||||
|
const existingSection = picker ? picker.querySelector("[data-reaction-existing]") : null;
|
||||||
|
const existingList = picker ? picker.querySelector("[data-reaction-existing-list]") : null;
|
||||||
|
const readonlyNote = picker ? picker.querySelector("[data-reaction-readonly]") : null;
|
||||||
|
const chooserElements = picker ? Array.from(picker.querySelectorAll(".reaction-picker-tabs, .reaction-search, .reaction-grid-wrap")) : [];
|
||||||
|
const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : [];
|
||||||
|
const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : [];
|
||||||
|
|
||||||
|
let activeButton = null;
|
||||||
|
let activeCard = null;
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-reaction-button]").forEach((button) => {
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
openPicker(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const pill = event.target.closest("[data-reaction-pill]");
|
||||||
|
if (pill) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const card = pill.closest("[data-reaction-card]") || activeCard;
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (card.dataset.reacted === "true") {
|
||||||
|
openPickerForCard(card, pill);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitReactionForCard(card, pill.dataset.reactionEmojiId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const more = event.target.closest("[data-reaction-more]");
|
||||||
|
if (!more) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const card = more.closest("[data-reaction-card]");
|
||||||
|
if (card) {
|
||||||
|
openPickerForCard(card, more);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!picker || !panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aurora's glass card uses backdrop-filter, and the main content animates
|
||||||
|
// with transform. Both can create a containing block for fixed descendants,
|
||||||
|
// so keep the floating picker at body level where viewport coordinates mean
|
||||||
|
// what they say.
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
|
||||||
|
picker.addEventListener("click", (event) => {
|
||||||
|
if (event.target === picker) {
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.addEventListener("click", async (event) => {
|
||||||
|
const emoji = event.target.closest("[data-emoji-id]");
|
||||||
|
if (!emoji || !activeCard || activeCard.dataset.reacted === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await submitReactionForCard(activeCard, emoji.dataset.emojiId);
|
||||||
|
});
|
||||||
|
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
setActiveTab(tab.dataset.reactionTab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
search.addEventListener("input", () => filterEmoji(search.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeButton) {
|
||||||
|
closeButton.addEventListener("click", closePicker);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (picker.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest("[data-reaction-more]") || event.target.closest("[data-reaction-pill]")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closePicker();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (activeButton && !picker.hidden) {
|
||||||
|
positionPicker(activeButton);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPicker(button) {
|
||||||
|
openPickerForCard(button.closest("[data-reaction-card]"), button);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPickerForCard(card, trigger) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeButton = trigger || card.querySelector("[data-reaction-button]");
|
||||||
|
activeCard = card;
|
||||||
|
populateExistingReactions(card);
|
||||||
|
setPickerReadonly(card.dataset.reacted === "true");
|
||||||
|
picker.hidden = false;
|
||||||
|
picker.classList.add("is-open");
|
||||||
|
if (search) {
|
||||||
|
search.value = "";
|
||||||
|
filterEmoji("");
|
||||||
|
}
|
||||||
|
positionPicker(activeButton || card);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePicker() {
|
||||||
|
picker.hidden = true;
|
||||||
|
picker.classList.remove("is-open", "is-mobile");
|
||||||
|
document.documentElement.classList.remove("reaction-picker-open");
|
||||||
|
picker.style.left = "";
|
||||||
|
picker.style.top = "";
|
||||||
|
setPickerReadonly(false);
|
||||||
|
activeButton = null;
|
||||||
|
activeCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPicker(button) {
|
||||||
|
if (isMobilePicker()) {
|
||||||
|
picker.classList.add("is-mobile");
|
||||||
|
document.documentElement.classList.add("reaction-picker-open");
|
||||||
|
picker.style.left = "0px";
|
||||||
|
picker.style.top = "0px";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.classList.remove("is-mobile");
|
||||||
|
document.documentElement.classList.remove("reaction-picker-open");
|
||||||
|
picker.style.left = "0px";
|
||||||
|
picker.style.top = "0px";
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const pickerRect = panel.getBoundingClientRect();
|
||||||
|
const margin = 10;
|
||||||
|
const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2);
|
||||||
|
const preferredTop = buttonRect.bottom + 8;
|
||||||
|
const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin);
|
||||||
|
const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin);
|
||||||
|
picker.style.left = `${left}px`;
|
||||||
|
picker.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMobilePicker() {
|
||||||
|
return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTab(tabID) {
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
const active = tab.dataset.reactionTab === tabID;
|
||||||
|
tab.classList.toggle("is-active", active);
|
||||||
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||||
|
});
|
||||||
|
panels.forEach((item) => {
|
||||||
|
item.classList.toggle("is-active", item.dataset.reactionPanel === tabID);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterEmoji(value) {
|
||||||
|
const query = value.trim().toLowerCase();
|
||||||
|
picker.querySelectorAll("[data-emoji-id]").forEach((button) => {
|
||||||
|
const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase();
|
||||||
|
button.hidden = query !== "" && !haystack.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReactionForCard(card, emojiID) {
|
||||||
|
if (!card || !emojiID || card.dataset.reacted === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
body.set("emoji_id", emojiID);
|
||||||
|
|
||||||
|
const reactButton = card.querySelector("[data-reaction-button]");
|
||||||
|
if (reactButton) {
|
||||||
|
reactButton.disabled = true;
|
||||||
|
}
|
||||||
|
const response = await fetch(card.dataset.reactUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (reactButton) {
|
||||||
|
reactButton.disabled = false;
|
||||||
|
}
|
||||||
|
closePicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
renderReactions(card, payload.reactions || []);
|
||||||
|
card.dataset.reacted = "true";
|
||||||
|
if (reactButton) {
|
||||||
|
reactButton.remove();
|
||||||
|
}
|
||||||
|
closePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReactions(card, reactions) {
|
||||||
|
const list = card.querySelector("[data-reaction-list]");
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.replaceChildren();
|
||||||
|
reactions.forEach((reaction) => {
|
||||||
|
const pill = buildReactionPill(reaction);
|
||||||
|
if (!reaction.visible) {
|
||||||
|
pill.classList.add("is-hidden-summary");
|
||||||
|
}
|
||||||
|
list.append(pill);
|
||||||
|
});
|
||||||
|
const hiddenCount = reactions.length > 2 ? reactions.length - 2 : 0;
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const more = document.createElement("button");
|
||||||
|
more.className = "reaction-more";
|
||||||
|
more.type = "button";
|
||||||
|
more.dataset.reactionMore = "";
|
||||||
|
more.textContent = `+${hiddenCount}`;
|
||||||
|
more.setAttribute("aria-label", `Show ${hiddenCount} more reactions`);
|
||||||
|
list.append(more);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReactionPill(reaction) {
|
||||||
|
const pill = document.createElement("button");
|
||||||
|
pill.className = "reaction-pill";
|
||||||
|
pill.type = "button";
|
||||||
|
pill.title = reaction.label || reaction.emojiId;
|
||||||
|
pill.dataset.reactionPill = "";
|
||||||
|
pill.dataset.reactionEmojiId = reaction.emojiId;
|
||||||
|
pill.dataset.reactionLabel = reaction.label || reaction.emojiId;
|
||||||
|
pill.dataset.reactionUrl = reaction.url;
|
||||||
|
pill.dataset.reactionCount = reaction.count;
|
||||||
|
pill.setAttribute("aria-label", `React with ${reaction.label || reaction.emojiId}`);
|
||||||
|
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = reaction.url;
|
||||||
|
image.alt = reaction.label || reaction.emojiId;
|
||||||
|
image.loading = "lazy";
|
||||||
|
|
||||||
|
const count = document.createElement("span");
|
||||||
|
count.textContent = reaction.count;
|
||||||
|
|
||||||
|
pill.append(image, count);
|
||||||
|
return pill;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateExistingReactions(card) {
|
||||||
|
if (!existingSection || !existingList) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existingList.replaceChildren();
|
||||||
|
card.querySelectorAll("[data-reaction-pill]").forEach((pill) => {
|
||||||
|
const clone = pill.cloneNode(true);
|
||||||
|
clone.classList.remove("is-hidden-summary");
|
||||||
|
existingList.append(clone);
|
||||||
|
});
|
||||||
|
existingSection.hidden = existingList.children.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPickerReadonly(readonly) {
|
||||||
|
picker.classList.toggle("is-readonly", readonly);
|
||||||
|
chooserElements.forEach((element) => {
|
||||||
|
element.hidden = readonly;
|
||||||
|
});
|
||||||
|
if (readonlyNote) {
|
||||||
|
readonlyNote.hidden = !readonly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
const copyURL = document.querySelector("#copy-url");
|
const copyURL = document.querySelector("#copy-url");
|
||||||
const openBox = document.querySelector("#open-box");
|
const openBox = document.querySelector("#open-box");
|
||||||
const manageLink = document.querySelector("#manage-link");
|
const manageLink = document.querySelector("#manage-link");
|
||||||
|
const newUpload = document.querySelector("#new-upload");
|
||||||
|
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
||||||
|
|
||||||
if (!form || !dropZone || !fileInput) {
|
if (!form || !dropZone || !fileInput) {
|
||||||
return;
|
return;
|
||||||
@@ -42,6 +44,9 @@
|
|||||||
|
|
||||||
let latestBoxURL = "";
|
let latestBoxURL = "";
|
||||||
let selectedFiles = [];
|
let selectedFiles = [];
|
||||||
|
let uploadLocked = false;
|
||||||
|
let recoveredDraft = null;
|
||||||
|
let resumeMode = false;
|
||||||
|
|
||||||
["dragenter", "dragover"].forEach((eventName) => {
|
["dragenter", "dragover"].forEach((eventName) => {
|
||||||
dropZone.addEventListener(eventName, (event) => {
|
dropZone.addEventListener(eventName, (event) => {
|
||||||
@@ -57,33 +62,65 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener("drop", (event) => {
|
document.addEventListener("dragover", (event) => {
|
||||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
|
||||||
fileInput.files = event.dataTransfer.files;
|
event.preventDefault();
|
||||||
updateSelectedState(event.dataTransfer.files);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fileInput.addEventListener("change", () => updateSelectedState(fileInput.files));
|
document.addEventListener("drop", (event) => {
|
||||||
|
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dropZone.contains(event.target)) {
|
||||||
|
addSelectedFiles(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener("drop", (event) => {
|
||||||
|
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||||
|
addSelectedFiles(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener("change", () => {
|
||||||
|
addSelectedFiles(fileInput.files);
|
||||||
|
fileInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener("submit", async (event) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!fileInput.files || fileInput.files.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
updateStatus("Choose at least one file first.");
|
updateStatus("Choose at least one file first.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = form.querySelector("button[type='submit']");
|
const submit = form.querySelector("button[type='submit']");
|
||||||
const formData = new FormData(form);
|
const formData = uploadFormData();
|
||||||
selectedFiles = Array.from(fileInput.files);
|
if (resumeMode && recoveredDraft) {
|
||||||
renderQueue(selectedFiles, "queued");
|
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||||
|
} else {
|
||||||
|
renderQueue(selectedFiles, "queued");
|
||||||
|
}
|
||||||
setLoading(true, submit);
|
setLoading(true, submit);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
||||||
renderResult(payload);
|
renderResult(payload);
|
||||||
form.reset();
|
form.reset();
|
||||||
updateSelectedState([]);
|
selectedFiles = [];
|
||||||
|
resumeMode = false;
|
||||||
|
recoveredDraft = null;
|
||||||
|
fileInput.value = "";
|
||||||
|
if (uploadQueue) {
|
||||||
|
uploadQueue.hidden = true;
|
||||||
|
uploadQueue.replaceChildren();
|
||||||
|
}
|
||||||
|
updateNewUploadVisibility();
|
||||||
|
if (fileSummary) {
|
||||||
|
fileSummary.textContent = "Upload complete.";
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateStatus(error.message || "Upload failed");
|
updateStatus(error.message || "Upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -97,25 +134,73 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedState(files) {
|
if (newUpload) {
|
||||||
selectedFiles = Array.from(files || []);
|
newUpload.addEventListener("click", () => {
|
||||||
|
cancelRecoveredDraft().catch((error) => {
|
||||||
|
updateStatus(error.message || "Upload draft could not be deleted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recoverResumableSessions();
|
||||||
|
|
||||||
|
function addSelectedFiles(files) {
|
||||||
|
if (uploadLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Array.from(files || []).forEach((file) => {
|
||||||
|
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
|
||||||
|
selectedFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateSelectedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSelectedFile(index) {
|
||||||
|
if (uploadLocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedFiles.splice(index, 1);
|
||||||
|
updateSelectedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedState() {
|
||||||
const count = selectedFiles.length || 0;
|
const count = selectedFiles.length || 0;
|
||||||
const title = dropZone.querySelector(".drop-title");
|
const title = dropZone.querySelector(".drop-title");
|
||||||
if (title) {
|
if (title) {
|
||||||
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
||||||
}
|
}
|
||||||
if (fileSummary) {
|
if (fileSummary) {
|
||||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
if (resumeMode && recoveredDraft) {
|
||||||
|
fileSummary.textContent = count === 0
|
||||||
|
? "Reselect missing files to resume, or add extra files to this upload."
|
||||||
|
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`;
|
||||||
|
} else {
|
||||||
|
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (count > 0) {
|
if (resumeMode && recoveredDraft) {
|
||||||
|
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||||
|
} else if (count > 0) {
|
||||||
renderQueue(selectedFiles, "queued");
|
renderQueue(selectedFiles, "queued");
|
||||||
} else if (uploadQueue) {
|
} else if (uploadQueue) {
|
||||||
uploadQueue.hidden = true;
|
uploadQueue.hidden = true;
|
||||||
uploadQueue.replaceChildren();
|
uploadQueue.replaceChildren();
|
||||||
}
|
}
|
||||||
|
updateNewUploadVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateNewUploadVisibility() {
|
||||||
|
if (!newUpload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = Boolean(resumeMode && recoveredDraft);
|
||||||
|
newUpload.hidden = !visible;
|
||||||
|
newUpload.style.display = visible ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLoading(isLoading, submit) {
|
function setLoading(isLoading, submit) {
|
||||||
|
uploadLocked = isLoading;
|
||||||
if (progress) {
|
if (progress) {
|
||||||
progress.hidden = !isLoading;
|
progress.hidden = !isLoading;
|
||||||
}
|
}
|
||||||
@@ -123,6 +208,9 @@
|
|||||||
submit.disabled = isLoading;
|
submit.disabled = isLoading;
|
||||||
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
||||||
}
|
}
|
||||||
|
if (newUpload) {
|
||||||
|
newUpload.disabled = isLoading;
|
||||||
|
}
|
||||||
updateStatus(isLoading ? "Transferring files..." : "");
|
updateStatus(isLoading ? "Transferring files..." : "");
|
||||||
setTotalProgress(isLoading ? 0 : 100);
|
setTotalProgress(isLoading ? 0 : 100);
|
||||||
}
|
}
|
||||||
@@ -133,20 +221,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUploadProgress(percent, bytesPerSecond) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, Math.round(percent || 0)));
|
||||||
|
const rate = formatTransferRate(bytesPerSecond);
|
||||||
|
updateStatus(rate ? `${clamped}% · ${rate}` : `${clamped}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTransferRateTracker(initialBytes) {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const baseline = Math.max(0, initialBytes || 0);
|
||||||
|
let lastRate = 0;
|
||||||
|
return function track(currentBytes) {
|
||||||
|
const elapsedSeconds = (performance.now() - startedAt) / 1000;
|
||||||
|
const transferred = Math.max(0, (currentBytes || 0) - baseline);
|
||||||
|
if (elapsedSeconds < 0.25 || transferred <= 0) {
|
||||||
|
return lastRate;
|
||||||
|
}
|
||||||
|
lastRate = transferred / elapsedSeconds;
|
||||||
|
return lastRate;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransferRate(bytesPerSecond) {
|
||||||
|
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const units = ["b/s", "Kb/s", "Mb/s", "Gb/s"];
|
||||||
|
let value = bytesPerSecond * 8;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1000 && unit < units.length - 1) {
|
||||||
|
value /= 1000;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderResult(payload) {
|
function renderResult(payload) {
|
||||||
if (!result || !resultList || !resultMeta || !openBox) {
|
if (!result || !resultList || !resultMeta || !openBox) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
latestBoxURL = payload.boxUrl;
|
latestBoxURL = window.Warpbox.absoluteURL(payload.boxUrl);
|
||||||
result.hidden = false;
|
result.hidden = false;
|
||||||
openBox.href = payload.boxUrl;
|
openBox.href = latestBoxURL;
|
||||||
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
|
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${window.Warpbox.formatDate(payload.expiresAt)}`;
|
||||||
if (manageLink) {
|
if (manageLink) {
|
||||||
const anchor = manageLink.querySelector("a");
|
const anchor = manageLink.querySelector("a");
|
||||||
manageLink.hidden = !payload.manageUrl;
|
manageLink.hidden = !payload.manageUrl;
|
||||||
if (anchor && payload.manageUrl) {
|
if (anchor && payload.manageUrl) {
|
||||||
anchor.href = payload.manageUrl;
|
anchor.href = window.Warpbox.absoluteURL(payload.manageUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,26 +277,29 @@
|
|||||||
payload.files.forEach((file) => {
|
payload.files.forEach((file) => {
|
||||||
resultList.append(createFileRow({
|
resultList.append(createFileRow({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
meta: `${file.size} · ${file.url}`,
|
meta: `${file.size} · ${window.Warpbox.absoluteURL(file.url)}`,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
status: "complete",
|
status: "complete",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
result.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadWithProgress(url, formData, files) {
|
function uploadWithProgress(url, formData, files) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = new XMLHttpRequest();
|
const request = new XMLHttpRequest();
|
||||||
|
const rateTracker = createTransferRateTracker(0);
|
||||||
request.open("POST", url);
|
request.open("POST", url);
|
||||||
request.setRequestHeader("Accept", "application/json");
|
request.setRequestHeader("Accept", "application/json");
|
||||||
|
|
||||||
request.upload.addEventListener("progress", (event) => {
|
request.upload.addEventListener("progress", (event) => {
|
||||||
|
const rate = rateTracker(event.loaded || 0);
|
||||||
if (!event.lengthComputable) {
|
if (!event.lengthComputable) {
|
||||||
updateStatus("Uploading...");
|
updateStatus(rate > 0 ? `Uploading · ${formatTransferRate(rate)}` : "Uploading...");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const percent = Math.round((event.loaded / event.total) * 100);
|
const percent = Math.round((event.loaded / event.total) * 100);
|
||||||
updateStatus(`${percent}%`);
|
updateUploadProgress(percent, rate);
|
||||||
setTotalProgress(percent);
|
setTotalProgress(percent);
|
||||||
setFileProgress(files, percent);
|
setFileProgress(files, percent);
|
||||||
});
|
});
|
||||||
@@ -201,26 +327,617 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadResumable(fallbackUrl, formData, files) {
|
||||||
|
if (!window.fetch || typeof Blob === "undefined") {
|
||||||
|
return uploadWithProgress(fallbackUrl, formData, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus("Fingerprinting files...");
|
||||||
|
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
|
||||||
|
const createPayload = {
|
||||||
|
files: files.map((file, index) => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
contentType: file.type || "application/octet-stream",
|
||||||
|
fingerprint: fingerprints[index],
|
||||||
|
})),
|
||||||
|
expiresMinutes: parseInt(formData.get("expires_minutes") || "0", 10) || 0,
|
||||||
|
maxDownloads: parseInt(formData.get("max_downloads") || "0", 10) || 0,
|
||||||
|
password: formData.get("password") || "",
|
||||||
|
obfuscateMetadata: formData.get("obfuscate_metadata") === "on",
|
||||||
|
collectionId: formData.get("collection_id") || "",
|
||||||
|
};
|
||||||
|
const persistable = !createPayload.password;
|
||||||
|
let session = null;
|
||||||
|
if (persistable && resumeMode && recoveredDraft) {
|
||||||
|
session = await fetchResumableStatus(recoveredDraft.session.sessionId, recoveredDraft.session.resumeToken);
|
||||||
|
session.resumeToken = recoveredDraft.session.resumeToken;
|
||||||
|
} else if (persistable) {
|
||||||
|
session = await findResumableSession(createPayload);
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
validateResumeSelection(session, createPayload);
|
||||||
|
session = await addMissingResumableFiles(session, createPayload);
|
||||||
|
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
|
||||||
|
recoveredDraft.session = session;
|
||||||
|
}
|
||||||
|
if (persistable) {
|
||||||
|
saveResumableSession(session, createPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!session || session.status !== "uploading") {
|
||||||
|
try {
|
||||||
|
session = await createResumableSession(createPayload);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error.message || "").toLowerCase().includes("resumable uploads are disabled")) {
|
||||||
|
return uploadWithProgress(fallbackUrl, formData, files);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (persistable) {
|
||||||
|
saveResumableSession(session, createPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index]));
|
||||||
|
if (sessionFiles.some((file) => !file)) {
|
||||||
|
throw new Error("Upload session could not match the selected files");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus("Uploading...");
|
||||||
|
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
const completedByFile = new Array(files.length).fill(0);
|
||||||
|
sessionFiles.forEach((sessionFile, index) => {
|
||||||
|
completedByFile[index] = uploadedBytesForSessionFile(sessionFile, session.chunkSize);
|
||||||
|
setSingleFileProgress(index, files[index], percentForBytes(completedByFile[index], files[index].size));
|
||||||
|
});
|
||||||
|
const initiallyUploadedBytes = completedByFile.reduce((sum, bytes) => sum + bytes, 0);
|
||||||
|
const rateTracker = createTransferRateTracker(initiallyUploadedBytes);
|
||||||
|
setTotalProgress(percentForBytes(initiallyUploadedBytes, totalBytes));
|
||||||
|
|
||||||
|
for (let fileIndex = 0; fileIndex < files.length; fileIndex++) {
|
||||||
|
const file = files[fileIndex];
|
||||||
|
const sessionFile = sessionFiles[fileIndex];
|
||||||
|
const uploaded = new Set(sessionFile.uploadedChunks || []);
|
||||||
|
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
|
||||||
|
if (uploaded.has(chunkIndex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const start = chunkIndex * session.chunkSize;
|
||||||
|
const end = Math.min(file.size, start + session.chunkSize);
|
||||||
|
await uploadChunkWithRetry(session, sessionFile, chunkIndex, file.slice(start, end), (loaded) => {
|
||||||
|
const currentTotal = completedByFile.reduce((sum, bytes) => sum + bytes, 0) + loaded;
|
||||||
|
const percent = percentForBytes(currentTotal, totalBytes);
|
||||||
|
const rate = rateTracker(currentTotal);
|
||||||
|
setTotalProgress(percent);
|
||||||
|
setSingleFileProgress(fileIndex, file, percentForBytes(completedByFile[fileIndex] + loaded, file.size));
|
||||||
|
updateUploadProgress(percent, rate);
|
||||||
|
});
|
||||||
|
completedByFile[fileIndex] += end - start;
|
||||||
|
uploaded.add(chunkIndex);
|
||||||
|
sessionFile.uploadedChunks = Array.from(uploaded).sort((a, b) => a - b);
|
||||||
|
if (persistable) {
|
||||||
|
saveResumableSession(session, createPayload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSingleFileProgress(fileIndex, file, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus("Finalizing upload...");
|
||||||
|
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
|
||||||
|
const wasResumeMode = resumeMode;
|
||||||
|
if (persistable) {
|
||||||
|
removeResumableSession(session.sessionId);
|
||||||
|
}
|
||||||
|
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
|
||||||
|
resumeMode = false;
|
||||||
|
recoveredDraft = null;
|
||||||
|
}
|
||||||
|
setTotalProgress(100);
|
||||||
|
if (!wasResumeMode) {
|
||||||
|
setFileProgress(files, 100);
|
||||||
|
}
|
||||||
|
return resultPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createResumableSession(payload) {
|
||||||
|
const response = await fetch("/api/v1/uploads/resumable", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return readUploadJSON(response, "Upload session could not be created");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchResumableStatus(sessionID, resumeToken) {
|
||||||
|
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, {
|
||||||
|
headers: resumableHeaders(resumeToken),
|
||||||
|
});
|
||||||
|
return readUploadJSON(response, "Upload session could not be resumed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addResumableFiles(sessionID, resumeToken, files) {
|
||||||
|
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...resumableHeaders(resumeToken),
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ files }),
|
||||||
|
});
|
||||||
|
return readUploadJSON(response, "Upload session files could not be added");
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`);
|
||||||
|
request.setRequestHeader("Accept", "application/json");
|
||||||
|
request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || "");
|
||||||
|
request.upload.addEventListener("progress", (event) => {
|
||||||
|
if (event.lengthComputable && onProgress) {
|
||||||
|
onProgress(event.loaded);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
request.addEventListener("load", () => {
|
||||||
|
if (request.status < 200 || request.status >= 300) {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(request.responseText || "{}");
|
||||||
|
} catch (error) {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
reject(new Error(payload.error || "Chunk upload failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
request.addEventListener("error", () => reject(new Error("Network error during chunk upload")));
|
||||||
|
request.addEventListener("abort", () => reject(new Error("Chunk upload aborted")));
|
||||||
|
request.send(chunk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadChunkWithRetry(session, sessionFile, chunkIndex, chunk, onProgress) {
|
||||||
|
const delays = [1000, 2000, 5000, 10000, 20000];
|
||||||
|
let lastError = null;
|
||||||
|
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
||||||
|
try {
|
||||||
|
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt >= delays.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const seconds = Math.round(delays[attempt] / 1000);
|
||||||
|
updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`);
|
||||||
|
await wait(delays[attempt]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError || new Error("Chunk upload failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeResumableSession(sessionID, resumeToken) {
|
||||||
|
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/complete`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: resumableHeaders(resumeToken),
|
||||||
|
});
|
||||||
|
return readUploadJSON(response, "Upload could not be completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelResumableSession(sessionID, resumeToken) {
|
||||||
|
const response = await fetch(`/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: resumableHeaders(resumeToken),
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
await readUploadJSON(response, "Upload draft could not be deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumableHeaders(resumeToken) {
|
||||||
|
return {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Warpbox-Resume-Token": resumeToken || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(ms) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readUploadJSON(response, fallback) {
|
||||||
|
let payload = {};
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.error || fallback);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findResumableSession(payload) {
|
||||||
|
const records = loadResumableSessions();
|
||||||
|
const optionKey = resumableOptionKey(payload);
|
||||||
|
const selectedKeys = new Set(payload.files.map((file) => resumableFileKey(file)));
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.optionKey !== optionKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!record.files || !record.files.some((file) => selectedKeys.has(resumableFileKey(file)))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null);
|
||||||
|
if (!session || session.status !== "uploading") {
|
||||||
|
removeResumableSession(record.sessionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
session.resumeToken = record.resumeToken;
|
||||||
|
const sessionKeys = new Set(session.files.map((file) => resumableFileKey(file)));
|
||||||
|
const selectedContainsSessionFile = Array.from(sessionKeys).some((key) => selectedKeys.has(key));
|
||||||
|
if (selectedContainsSessionFile) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMissingResumableFiles(session, payload) {
|
||||||
|
const existing = new Set(session.files.map((file) => resumableFileKey(file)));
|
||||||
|
const missing = payload.files.filter((file) => !existing.has(resumableFileKey(file)));
|
||||||
|
if (missing.length === 0) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
const updated = await addResumableFiles(session.sessionId, session.resumeToken, missing);
|
||||||
|
updated.resumeToken = session.resumeToken;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateResumeSelection(session, payload) {
|
||||||
|
if (!resumeMode || !recoveredDraft || session.sessionId !== recoveredDraft.session.sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existingByNameSize = new Map();
|
||||||
|
(session.files || []).forEach((file) => {
|
||||||
|
existingByNameSize.set(`${file.name}:${file.size}`, resumableFileKey(file));
|
||||||
|
});
|
||||||
|
for (const file of payload.files || []) {
|
||||||
|
const expectedKey = existingByNameSize.get(`${file.name}:${file.size}`);
|
||||||
|
if (expectedKey && expectedKey !== resumableFileKey(file)) {
|
||||||
|
throw new Error(`"${file.name}" does not match the pending upload. Select the exact original file.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSessionFile(session, file) {
|
||||||
|
const key = resumableFileKey(file);
|
||||||
|
return session.files.find((sessionFile) => resumableFileKey(sessionFile) === key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumableOptionKey(payload) {
|
||||||
|
return [
|
||||||
|
payload.expiresMinutes,
|
||||||
|
payload.maxDownloads,
|
||||||
|
payload.obfuscateMetadata ? "1" : "0",
|
||||||
|
payload.collectionId || "",
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumableFileKey(file) {
|
||||||
|
return [file.name, file.size, file.fingerprint || ""].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadResumableSessions() {
|
||||||
|
try {
|
||||||
|
const value = localStorage.getItem(RESUMABLE_SESSIONS_KEY);
|
||||||
|
const records = value ? JSON.parse(value) : [];
|
||||||
|
return Array.isArray(records) ? records : [];
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveResumableSession(session, payload) {
|
||||||
|
try {
|
||||||
|
const records = loadResumableSessions().filter((record) => record.sessionId !== session.sessionId);
|
||||||
|
records.push({
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
resumeToken: session.resumeToken || "",
|
||||||
|
optionKey: resumableOptionKey(payload),
|
||||||
|
options: {
|
||||||
|
expiresMinutes: payload.expiresMinutes,
|
||||||
|
maxDownloads: payload.maxDownloads,
|
||||||
|
obfuscateMetadata: !!payload.obfuscateMetadata,
|
||||||
|
collectionId: payload.collectionId || "",
|
||||||
|
},
|
||||||
|
files: session.files.map((file) => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
contentType: file.contentType || "application/octet-stream",
|
||||||
|
fingerprint: file.fingerprint || "",
|
||||||
|
uploadedChunks: file.uploadedChunks || [],
|
||||||
|
chunkCount: file.chunkCount || 0,
|
||||||
|
})),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records.slice(-25)));
|
||||||
|
} catch (error) {
|
||||||
|
/* ignore persistence failures */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverResumableSessions() {
|
||||||
|
const records = loadResumableSessions()
|
||||||
|
.filter((record) => record.sessionId && record.resumeToken)
|
||||||
|
.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime());
|
||||||
|
if (records.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const record of records) {
|
||||||
|
const session = await fetchResumableStatus(record.sessionId, record.resumeToken).catch(() => null);
|
||||||
|
if (!session || session.status !== "uploading") {
|
||||||
|
removeResumableSession(record.sessionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
session.resumeToken = record.resumeToken;
|
||||||
|
recoveredDraft = { session, record };
|
||||||
|
selectedFiles = [];
|
||||||
|
renderRecoveredQueue([{ session, record }]);
|
||||||
|
updateRecoveredSummary(session);
|
||||||
|
showRecoveryModal(recoveredDraft);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRecoveredSummary(session) {
|
||||||
|
updateStatus("Unfinished upload found. Choose how to continue.");
|
||||||
|
if (fileSummary) {
|
||||||
|
const totalFiles = (session.files || []).length;
|
||||||
|
const completedFiles = completedSessionFiles(session).length;
|
||||||
|
fileSummary.textContent = `Recovered ${totalFiles} pending file${totalFiles === 1 ? "" : "s"}; ${completedFiles} fully uploaded.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeResumableSession(sessionID) {
|
||||||
|
try {
|
||||||
|
const records = loadResumableSessions().filter((record) => record.sessionId !== sessionID);
|
||||||
|
localStorage.setItem(RESUMABLE_SESSIONS_KEY, JSON.stringify(records));
|
||||||
|
} catch (error) {
|
||||||
|
/* ignore persistence failures */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedSessionFiles(session) {
|
||||||
|
return (session.files || []).filter((file) => (file.uploadedChunks || []).length >= file.chunkCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRecoveryModal(draft) {
|
||||||
|
const old = document.querySelector(".upload-recovery-overlay");
|
||||||
|
if (old) {
|
||||||
|
old.remove();
|
||||||
|
}
|
||||||
|
const completeCount = completedSessionFiles(draft.session).length;
|
||||||
|
const totalCount = (draft.session.files || []).length;
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "upload-recovery-overlay";
|
||||||
|
overlay.setAttribute("role", "dialog");
|
||||||
|
overlay.setAttribute("aria-modal", "true");
|
||||||
|
overlay.setAttribute("aria-labelledby", "upload-recovery-title");
|
||||||
|
|
||||||
|
const modal = document.createElement("div");
|
||||||
|
modal.className = "upload-recovery-modal card";
|
||||||
|
const content = document.createElement("div");
|
||||||
|
content.className = "card-content";
|
||||||
|
|
||||||
|
const title = document.createElement("h2");
|
||||||
|
title.id = "upload-recovery-title";
|
||||||
|
title.textContent = "Unfinished upload found";
|
||||||
|
const copy = document.createElement("p");
|
||||||
|
copy.textContent = `Warpbox found a private draft with ${totalCount} file${totalCount === 1 ? "" : "s"}. ${completeCount} file${completeCount === 1 ? " is" : "s are"} already fully uploaded.`;
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "upload-recovery-actions";
|
||||||
|
|
||||||
|
const startOver = document.createElement("button");
|
||||||
|
startOver.type = "button";
|
||||||
|
startOver.className = "button button-danger";
|
||||||
|
startOver.textContent = "New Upload";
|
||||||
|
startOver.addEventListener("click", async () => {
|
||||||
|
startOver.disabled = true;
|
||||||
|
try {
|
||||||
|
await cancelRecoveredDraft();
|
||||||
|
overlay.remove();
|
||||||
|
} catch (error) {
|
||||||
|
startOver.disabled = false;
|
||||||
|
updateStatus(error.message || "Upload draft could not be deleted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const resume = document.createElement("button");
|
||||||
|
resume.type = "button";
|
||||||
|
resume.className = "button button-primary";
|
||||||
|
resume.textContent = "Resume";
|
||||||
|
resume.addEventListener("click", () => {
|
||||||
|
resumeRecoveredDraft();
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(startOver, resume);
|
||||||
|
content.append(title, copy, actions);
|
||||||
|
modal.append(content);
|
||||||
|
overlay.append(modal);
|
||||||
|
document.body.append(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRecoveredDraft() {
|
||||||
|
if (!recoveredDraft) {
|
||||||
|
resetFreshUploadState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const draft = recoveredDraft;
|
||||||
|
updateStatus("Deleting unfinished upload...");
|
||||||
|
await cancelResumableSession(draft.session.sessionId, draft.session.resumeToken);
|
||||||
|
removeResumableSession(draft.session.sessionId);
|
||||||
|
resetFreshUploadState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumeRecoveredDraft() {
|
||||||
|
if (!recoveredDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resumeMode = true;
|
||||||
|
selectedFiles = [];
|
||||||
|
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||||
|
updateSelectedState();
|
||||||
|
updateNewUploadVisibility();
|
||||||
|
updateStatus("Drop or reselect missing files to continue. Extra files will be added to this upload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFreshUploadState() {
|
||||||
|
selectedFiles = [];
|
||||||
|
resumeMode = false;
|
||||||
|
recoveredDraft = null;
|
||||||
|
fileInput.value = "";
|
||||||
|
result.hidden = true;
|
||||||
|
if (resultList) {
|
||||||
|
resultList.replaceChildren();
|
||||||
|
}
|
||||||
|
setTotalProgress(0);
|
||||||
|
updateStatus("");
|
||||||
|
updateSelectedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadedBytesForSessionFile(file, chunkSize) {
|
||||||
|
return (file.uploadedChunks || []).reduce((sum, index) => {
|
||||||
|
const start = index * chunkSize;
|
||||||
|
const end = Math.min(file.size, start + chunkSize);
|
||||||
|
return sum + Math.max(0, end - start);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecoveredQueue(items) {
|
||||||
|
if (!uploadQueue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
items.forEach(({ session }) => {
|
||||||
|
(session.files || []).forEach((file) => {
|
||||||
|
const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize);
|
||||||
|
const complete = (file.uploadedChunks || []).length >= file.chunkCount;
|
||||||
|
rows.push({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
uploadedBytes,
|
||||||
|
meta: complete
|
||||||
|
? `${window.Warpbox.formatBytes(file.size)} · uploaded`
|
||||||
|
: `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · Drop/reselect this file to continue`,
|
||||||
|
progress: percentForBytes(uploadedBytes, file.size),
|
||||||
|
status: complete ? "complete" : "waiting",
|
||||||
|
readonly: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
uploadQueue.hidden = rows.length === 0;
|
||||||
|
uploadQueue.replaceChildren();
|
||||||
|
rows.forEach((row) => uploadQueue.append(createFileRow(row)));
|
||||||
|
const totalBytes = rows.reduce((sum, row) => sum + (row.size || 0), 0);
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
setTotalProgress(percentForBytes(rows.reduce((sum, row) => sum + (row.uploadedBytes || 0), 0), totalBytes));
|
||||||
|
} else if (rows.length > 0) {
|
||||||
|
const completed = rows.filter((row) => row.status === "complete").length;
|
||||||
|
setTotalProgress(percentForBytes(completed, rows.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResumeQueue(session, localFiles) {
|
||||||
|
if (!uploadQueue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
const localByNameSize = new Map();
|
||||||
|
(localFiles || []).forEach((file, index) => {
|
||||||
|
localByNameSize.set(`${file.name}:${file.size}`, { file, index });
|
||||||
|
});
|
||||||
|
const usedLocalIndexes = new Set();
|
||||||
|
(session.files || []).forEach((file) => {
|
||||||
|
const uploadedBytes = uploadedBytesForSessionFile(file, session.chunkSize);
|
||||||
|
const complete = (file.uploadedChunks || []).length >= file.chunkCount;
|
||||||
|
const localMatch = localByNameSize.get(`${file.name}:${file.size}`) || null;
|
||||||
|
if (localMatch) {
|
||||||
|
usedLocalIndexes.add(localMatch.index);
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
uploadedBytes,
|
||||||
|
meta: complete
|
||||||
|
? `${window.Warpbox.formatBytes(file.size)} · uploaded`
|
||||||
|
: localMatch
|
||||||
|
? `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · ready to resume`
|
||||||
|
: `${window.Warpbox.formatBytes(uploadedBytes)} of ${window.Warpbox.formatBytes(file.size)} · waiting for local file`,
|
||||||
|
progress: percentForBytes(uploadedBytes, file.size),
|
||||||
|
status: complete ? "complete" : localMatch ? "queued" : "waiting",
|
||||||
|
readonly: !localMatch,
|
||||||
|
index: localMatch ? localMatch.index : undefined,
|
||||||
|
removable: Boolean(localMatch && !complete),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
(localFiles || []).forEach((file, index) => {
|
||||||
|
if (usedLocalIndexes.has(index)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
name: file.name,
|
||||||
|
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
|
||||||
|
progress: 0,
|
||||||
|
status: "queued",
|
||||||
|
index,
|
||||||
|
removable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
uploadQueue.hidden = rows.length === 0;
|
||||||
|
uploadQueue.replaceChildren();
|
||||||
|
rows.forEach((row) => uploadQueue.append(createFileRow(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentForBytes(bytes, total) {
|
||||||
|
if (!total) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
function renderQueue(files, status) {
|
function renderQueue(files, status) {
|
||||||
if (!uploadQueue) {
|
if (!uploadQueue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadQueue.hidden = files.length === 0;
|
uploadQueue.hidden = files.length === 0;
|
||||||
uploadQueue.replaceChildren();
|
uploadQueue.replaceChildren();
|
||||||
files.forEach((file) => {
|
files.forEach((file, index) => {
|
||||||
uploadQueue.append(createFileRow({
|
uploadQueue.append(createFileRow({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
meta: window.Warpbox.formatBytes(file.size),
|
meta: window.Warpbox.formatBytes(file.size),
|
||||||
progress: status === "queued" ? 0 : 100,
|
progress: status === "queued" ? 0 : 100,
|
||||||
status,
|
status,
|
||||||
|
index,
|
||||||
|
removable: status === "queued",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFileRow(file) {
|
function createFileRow(file) {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "result-item upload-file-row";
|
row.className = `result-item upload-file-row upload-file-${file.status || "queued"}`;
|
||||||
row.dataset.fileName = file.name;
|
row.dataset.fileName = file.name;
|
||||||
|
if (typeof file.index === "number") {
|
||||||
|
row.dataset.fileIndex = file.index;
|
||||||
|
}
|
||||||
|
|
||||||
const body = document.createElement("span");
|
const body = document.createElement("span");
|
||||||
const name = document.createElement("strong");
|
const name = document.createElement("strong");
|
||||||
@@ -242,11 +959,53 @@
|
|||||||
fill.style.transform = `scaleX(${file.progress / 100})`;
|
fill.style.transform = `scaleX(${file.progress / 100})`;
|
||||||
bar.append(fill);
|
bar.append(fill);
|
||||||
side.append(percent, bar);
|
side.append(percent, bar);
|
||||||
|
if (file.status === "waiting") {
|
||||||
|
const badge = document.createElement("small");
|
||||||
|
badge.className = "upload-file-state";
|
||||||
|
badge.textContent = "Needs local file";
|
||||||
|
side.append(badge);
|
||||||
|
}
|
||||||
|
if (file.removable) {
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.className = "upload-file-remove";
|
||||||
|
remove.type = "button";
|
||||||
|
remove.setAttribute("aria-label", `Remove ${file.name}`);
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.addEventListener("click", () => removeSelectedFile(file.index || 0));
|
||||||
|
side.append(remove);
|
||||||
|
}
|
||||||
|
|
||||||
row.append(body, side);
|
row.append(body, side);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uploadFormData() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.delete("file");
|
||||||
|
selectedFiles.forEach((file) => {
|
||||||
|
formData.append("file", file, file.name);
|
||||||
|
});
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIdentity(file) {
|
||||||
|
return [file.name, file.size, file.lastModified || 0].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileFingerprint(file) {
|
||||||
|
if (!window.crypto || !window.crypto.subtle || !file.slice || typeof TextEncoder === "undefined") {
|
||||||
|
return fileIdentity(file);
|
||||||
|
}
|
||||||
|
const sampleSize = Math.min(file.size, 1024 * 1024);
|
||||||
|
const sample = await file.slice(0, sampleSize).arrayBuffer();
|
||||||
|
const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":"));
|
||||||
|
const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
|
||||||
|
combined.set(metadata, 0);
|
||||||
|
combined.set(new Uint8Array(sample), metadata.byteLength);
|
||||||
|
const digest = await window.crypto.subtle.digest("SHA-256", combined);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function setTotalProgress(percent) {
|
function setTotalProgress(percent) {
|
||||||
if (totalProgressBar) {
|
if (totalProgressBar) {
|
||||||
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
||||||
@@ -271,4 +1030,23 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSingleFileProgress(index, file, progress) {
|
||||||
|
if (!uploadQueue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = uploadQueue.querySelector(`.upload-file-row[data-file-index="${index}"]`);
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const percent = row.querySelector(".file-progress-percent");
|
||||||
|
const fill = row.querySelector(".file-progress span");
|
||||||
|
const normalized = Math.max(0, Math.min(100, progress));
|
||||||
|
if (percent) {
|
||||||
|
percent.textContent = `${normalized}%`;
|
||||||
|
}
|
||||||
|
if (fill) {
|
||||||
|
fill.style.transform = `scaleX(${normalized / 100})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
1036
backend/static/js/45-preview.js
Normal file
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 |
24
backend/static/site.webmanifest
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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,17 +4,54 @@
|
|||||||
<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/10-layout.css?version={{.AppVersion}}">
|
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
||||||
@@ -31,11 +68,13 @@
|
|||||||
<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/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/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>
|
||||||
</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>
|
||||||
|
|||||||
@@ -14,13 +14,39 @@
|
|||||||
<h2>Endpoints</h2>
|
<h2>Endpoints</h2>
|
||||||
<dl class="endpoint-list">
|
<dl class="endpoint-list">
|
||||||
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
|
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
|
||||||
<div><dt>Health</dt><dd><code>GET /api/v1/health</code></dd></div>
|
<div><dt>Resumable create</dt><dd><code>POST /api/v1/uploads/resumable</code></dd></div>
|
||||||
|
<div><dt>Resumable status</dt><dd><code>GET /api/v1/uploads/resumable/{sessionID}</code></dd></div>
|
||||||
|
<div><dt>Resumable chunk</dt><dd><code>PUT /api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}</code></dd></div>
|
||||||
|
<div><dt>Resumable complete</dt><dd><code>POST /api/v1/uploads/resumable/{sessionID}/complete</code></dd></div>
|
||||||
|
<div><dt>Health</dt><dd><code>GET /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>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>
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="card docs-card docs-card-wide">
|
||||||
|
<div class="card-content">
|
||||||
|
<h2>Resumable uploads</h2>
|
||||||
|
<p>Browser uploads use the resumable API by default. Custom clients can use the same flow: create a session with file metadata, upload exact-sized chunks, then complete the session. Chunks are temporary and are cleaned if the session expires.</p>
|
||||||
|
<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}'
|
||||||
|
|
||||||
|
# 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>
|
||||||
|
<p class="muted-copy">For authenticated uploads, send the same <code>Authorization: Bearer <token></code> header on every resumable request. Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="card docs-card">
|
<article class="card docs-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>Curl upload</h2>
|
<h2>Curl upload</h2>
|
||||||
|
|||||||
@@ -24,51 +24,137 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Data.Files}}
|
{{if .Data.Files}}
|
||||||
|
{{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = 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}}
|
||||||
|
{{$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}}
|
||||||
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
|
{{if $single}}
|
||||||
<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>
|
{{$first := index .Data.Files 0}}
|
||||||
Download zip
|
<a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
|
||||||
</a>
|
<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>
|
||||||
|
{{else}}
|
||||||
|
<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>
|
||||||
|
Download zip
|
||||||
|
</a>
|
||||||
|
{{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>
|
||||||
</div>
|
<span>{{len .Data.Files}} item{{if ne (len .Data.Files) 1}}s{{end}}</span>
|
||||||
|
</div>
|
||||||
<div class="download-list file-browser is-list" data-file-browser>
|
<div class="file-browser-window-actions" aria-hidden="true">
|
||||||
{{range .Data.Files}}
|
<span></span><span></span><span></span>
|
||||||
<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}}">
|
</div>
|
||||||
<a class="thumb-link" href="{{.DownloadURL}}?inline=1" aria-label="View {{.Name}}">
|
</div>
|
||||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
<div class="file-browser-toolbar" aria-label="File view options">
|
||||||
</a>
|
<div class="view-toolbar">
|
||||||
<a class="file-main" href="{{.DownloadURL}}?inline=1">
|
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
|
||||||
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
|
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>
|
||||||
<small>{{.Size}} · {{.ContentType}}</small>
|
<span class="sr-only">List view</span>
|
||||||
</a>
|
</button>
|
||||||
{{if not $.Data.Locked}}
|
<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">
|
||||||
<div class="file-actions">
|
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
|
||||||
<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">
|
<span class="sr-only">Icon view</span>
|
||||||
<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>
|
</button>
|
||||||
<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>
|
</div>
|
||||||
<span data-preview-label>View</span>
|
</div>
|
||||||
</a>
|
<div class="file-browser-head" aria-hidden="true">
|
||||||
<a class="button button-outline" href="{{.DownloadURL}}" download="{{.Name}}">
|
<span>Name</span>
|
||||||
<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>Type</span>
|
||||||
Download
|
<span>Size</span>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<div class="download-list file-browser is-thumbs" data-file-browser>
|
||||||
{{end}}
|
{{range .Data.Files}}
|
||||||
</article>
|
<article class="download-item file-card {{if .Processing}}is-processing{{end}}" data-kind="{{.PreviewKind}}" {{if not .Processing}}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}}">
|
||||||
{{end}}
|
{{if .Processing}}<div class="file-open" aria-label="{{.Name}} is processing">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}}
|
||||||
|
<span class="file-media">
|
||||||
|
{{if .HasThumbnail}}
|
||||||
|
<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>
|
||||||
|
<small>{{.Size}} · {{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small>
|
||||||
|
</span>
|
||||||
|
<span class="file-type">{{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span>
|
||||||
|
<span class="file-size">{{.Size}}</span>
|
||||||
|
{{if .Processing}}</div>{{else}}</a>{{end}}
|
||||||
|
{{if not $.Data.Locked}}
|
||||||
|
<div class="file-reaction-dock" data-reaction-dock>
|
||||||
|
<div class="file-reactions" data-reaction-list>
|
||||||
|
{{range .Reactions}}
|
||||||
|
<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">
|
||||||
|
<span>{{.Count}}</span>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
{{if .ReactionMore}}
|
||||||
|
<button class="reaction-more" type="button" data-reaction-more aria-label="Show all reactions">+{{.ReactionMore}}</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if not .Reacted}}
|
||||||
|
<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>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{if not .Data.Locked}}
|
{{if not .Data.Locked}}
|
||||||
|
<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-head">
|
||||||
|
<strong>React</strong>
|
||||||
|
<button class="button button-ghost reaction-picker-close" type="button" data-reaction-close aria-label="Close reaction picker">Close</button>
|
||||||
|
</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">
|
||||||
|
{{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>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<label class="reaction-search">
|
||||||
|
<span class="sr-only">Search emoji</span>
|
||||||
|
<input type="search" data-reaction-search placeholder="Search emoji">
|
||||||
|
</label>
|
||||||
|
<div class="reaction-grid-wrap">
|
||||||
|
{{range $index, $tab := .Data.EmojiTabs}}
|
||||||
|
<div class="reaction-grid {{if eq $index 0}}is-active{{end}}" data-reaction-panel="{{$tab.ID}}" role="tabpanel">
|
||||||
|
{{range $tab.Emojis}}
|
||||||
|
<button class="reaction-emoji" type="button" data-emoji-id="{{.ID}}" data-emoji-label="{{.Label}}" title="{{.Label}}" aria-label="{{.Label}}">
|
||||||
|
<img src="{{.URL}}" alt="" loading="lazy">
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="context-menu" data-file-context-menu role="menu" aria-label="File actions" hidden>
|
<div class="context-menu" data-file-context-menu role="menu" aria-label="File actions" hidden>
|
||||||
<div class="context-menu-top">
|
<div class="context-menu-top">
|
||||||
<small>File actions</small>
|
<small>File actions</small>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="file-summary">Choose one or more files to begin.</p>
|
<p id="file-summary">Choose one or more files to begin.</p>
|
||||||
<button class="button button-primary" type="submit">Upload files</button>
|
<button class="button button-primary" type="submit">Upload files</button>
|
||||||
|
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{define "preview.html"}}{{template "base" .}}{{end}}
|
{{define "preview.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="download-view" aria-labelledby="preview-title">
|
<section class="download-view preview-view" aria-labelledby="preview-title">
|
||||||
<div class="card download-card">
|
<div class="card download-card preview-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
{{if .Data.Locked}}
|
{{if .Data.Locked}}
|
||||||
<div class="file-emblem" aria-hidden="true">
|
<div class="file-emblem" aria-hidden="true">
|
||||||
@@ -12,23 +12,70 @@
|
|||||||
<p class="download-subtitle">Unlock the box before viewing this file.</p>
|
<p class="download-subtitle">Unlock the box before viewing this file.</p>
|
||||||
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
|
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="preview-stage">
|
<header class="preview-header">
|
||||||
{{if eq .Data.File.PreviewKind "image"}}
|
<div class="preview-title-group">
|
||||||
<img src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}">
|
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
|
||||||
{{else if eq .Data.File.PreviewKind "video"}}
|
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
|
||||||
<video src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata"></video>
|
</div>
|
||||||
{{else if eq .Data.File.PreviewKind "audio"}}
|
<a class="button button-primary" href="{{.Data.DownloadURL}}">
|
||||||
<audio src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata"></audio>
|
<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>
|
||||||
{{else}}
|
Download
|
||||||
<img src="{{.Data.File.ThumbnailURL}}" alt="">
|
</a>
|
||||||
{{end}}
|
</header>
|
||||||
|
|
||||||
|
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}" data-archive-url="{{.Data.File.ArchiveURL}}">
|
||||||
|
<div class="preview-window-titlebar">
|
||||||
|
<div>
|
||||||
|
<strong data-preview-mode-label>Preview</strong>
|
||||||
|
<span>{{.Data.File.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-window-tools">
|
||||||
|
<button class="preview-fullscreen-button" type="button" data-render-fullscreen hidden>Full Screen</button>
|
||||||
|
<div class="preview-window-actions" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-tabs" data-preview-tabs></div>
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div class="default-preview" data-default-preview hidden>
|
||||||
|
<img src="{{.Data.File.IconURL}}" alt="" loading="lazy">
|
||||||
|
<div>
|
||||||
|
<strong title="{{.Data.File.Name}}">{{.Data.File.Name}}</strong>
|
||||||
|
<span>{{.Data.File.Size}} · {{.Data.File.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<a class="button button-primary" href="{{.Data.DownloadURL}}">
|
||||||
|
<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>
|
||||||
|
<img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden>
|
||||||
|
<video class="native-preview native-video-preview" data-video-preview src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata" hidden></video>
|
||||||
|
{{if .Data.File.HasScene}}<img class="native-preview video-scenes-preview" data-video-scenes-preview data-scene-src="{{.Data.File.SceneURL}}" alt="Scenes preview for {{.Data.File.Name}}" hidden>{{end}}
|
||||||
|
<audio class="native-preview native-audio-preview" data-browser-audio-preview src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata" hidden></audio>
|
||||||
|
<div class="code-preview raw-code-preview" data-raw-preview hidden>
|
||||||
|
<pre><code data-raw-output></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="code-preview prism-code-preview" data-code-preview hidden>
|
||||||
|
<pre class="line-numbers"><code data-code-output></code></pre>
|
||||||
|
</div>
|
||||||
|
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
|
||||||
|
<div class="archive-preview code-preview" data-archive-preview hidden>
|
||||||
|
<pre><code data-archive-output></code></pre>
|
||||||
|
</div>{{end}}
|
||||||
|
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
|
||||||
|
<div class="large-preview-gate" data-large-preview-gate hidden>
|
||||||
|
<strong>Large preview</strong>
|
||||||
|
<p>This file is larger than 500 KB. Loading this preview may be slow on some devices.</p>
|
||||||
|
<div>
|
||||||
|
<button class="button button-primary" type="button" data-large-preview-confirm>Load anyway</button>
|
||||||
|
<button class="button button-outline" type="button" data-large-preview-cancel>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-placeholder" data-preview-placeholder hidden>
|
||||||
|
<img src="{{.Data.File.IconURL}}" alt="">
|
||||||
|
<p>Preparing preview...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
|
|
||||||
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
|
|
||||||
<a class="button button-primary button-wide" href="{{.Data.DownloadURL}}">
|
|
||||||
<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 file
|
|
||||||
</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
5
scripts/env/dev.env.example
vendored
@@ -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=16
|
||||||
|
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
|
||||||
|
|||||||