35 Commits

Author SHA1 Message Date
e2cf7115b7 style(ui): add color-coded accents and tags to shortcut cards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m44s
Introduce per-card accent colors to enhance the visual hierarchy of the documentation shortcut cards. This includes adding left borders, colored eyebrows, and matching hover glows using CSS variables.

Additionally, this commit:
- Adds color-coded tags (GET, POST, JSON, etc.) for API links.
- Implements retro-themed styling for both the shortcut cards and tags to maintain consistency with the classic 16-color VGA palette.
- Applies the new accent classes to the API page template.
2026-06-11 11:32:22 +03:00
a0027fbd18 style(retro): style API documentation as Win98 windows
Re-skin the API documentation layout for the retro theme to ensure readability and maintain the Windows 98 aesthetic. The default dark revamp tokens were unreadable on the black retro desktop background.

Changes include:
- Styling the API sidebar as a raised silver window with a classic title bar.
- Styling endpoint cards as silver windows with navy title bars.
- Excluding API navigation links, shortcut cards, and link pills from default retro link styles to prevent styling conflicts.
- Updating API documentation content, including adding a section for resumable uploads.
2026-06-11 09:19:06 +03:00
6a7590493c refactor(upload): use IncomingFile interface instead of multipart headers
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m58s
Refactors the upload handler to use the `services.IncomingFile` interface instead of concrete `*multipart.FileHeader` pointers. This decouples the core upload logic from the HTTP multipart implementation, allowing for more flexible file sources.

Changes include:
- Introducing `namedMultipartFile` to adapt multipart headers to the new interface.
- Updating `createOrAppendBox`, `checkUploadPolicy`, and `totalUploadBytes` to accept `IncomingFile`.
- Renaming service calls to `CreateBoxFromIncoming` and `AppendIncomingFiles`.
2026-06-10 18:19:45 +03:00
5d77b36634 feat: support folder uploads and sanitize upload paths
- Implement `cleanUploadDisplayName` in the backend to safely sanitize uploaded file paths, preserving directory structures while stripping unsafe characters and preventing path traversal.
- Add folder upload capability in the frontend using the Directory Picker API.
- Implement desktop notifications for completed uploads.
2026-06-10 18:14:29 +03:00
0b8d4a3ab9 chore(copy): standardize metadata formatting and descriptions
- Replace middle dots (·) and em-dashes (—) with pipes (|) and standard punctuation in page titles, descriptions, and image alt texts.
- Shorten the homepage description to be more concise and direct.
- Update file share description phrasing for better readability, changing "click to preview" to "Open to preview" and capitalizing "Expires".
2026-06-10 12:56:22 +03:00
0b4487ac2e feat(upload): warn on large uploads over slow/metered connections
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m54s
Detects if the user is on a slow (2G/3G) or metered (saveData) connection
and prompts them with a confirmation dialog if they attempt to upload
files totaling 200MB or more.

This prevents accidental high data usage and warns users about potential
long upload times. Also includes the dialogs JS and CSS in the base
layout to support the confirmation modal.
2026-06-08 13:34:05 +03:00
ead4cd7492 refactor(download): migrate inline SVGs to CSS mask-based icons
Replaces inline SVG elements in the download template with a reusable
CSS mask-based icon system. This reduces HTML bloat and centralizes
icon management.

- Added a generic `.svg-icon` utility class using CSS masks.
- Defined specific icon classes mapping to static SVG assets.
- Updated `download.html` to use `<span>` tags with the new icon classes.
- Adjusted CSS selectors in retro and download stylesheets to target `.svg-icon`.
2026-06-08 12:08:51 +03:00
af1fae1a98 feat(download): add share button to download page
Introduce a new "Share" button on the download page to allow users to easily share the box link.

- Add the share button markup and SVG icon to `download.html`
- Include the `13-share.js` script in the base layout to handle the share action
- Add CSS styling for the share button in `30-download.css`
2026-06-08 12:02:30 +03:00
d11aec96e5 feat(backend): handle processing errors and add PWA routes
- Block file downloads and previews with a 424 StatusFailedDependency if file processing failed or the box has issues.
- Register routes for `/service-worker.js` and `/share-target` to support PWA features.
- Update README.md with an AI usage disclosure.
2026-06-08 11:53:37 +03:00
dbfdacc396 feat(download): support UTF-8 filenames in Content-Disposition
Improve the Content-Disposition header formatting for file downloads by implementing RFC 5987 compliant filename encoding. This ensures that downloaded files retain their original names, including spaces and non-ASCII characters, across different browsers.

- Add `contentDisposition` helper to generate both standard ASCII fallback and UTF-8 encoded filename parameters.
- Sanitize filenames to prevent path traversal and replace unsafe characters with underscores in the ASCII fallback.
- Update single file and ZIP downloads to use the new formatting.
- Add unit tests to verify correct header generation for various filename scenarios.
2026-06-08 10:53:20 +03:00
45507cdcae feat(ogimage): render custom OG images for archive files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m50s
Add support for generating and rendering rich Open Graph (OG) image cards for archive files. When an archive file is shared, the handler now fetches or generates its listing metadata and renders a custom card displaying file/folder counts, uncompressed size, and a visual representation of the archive's contents.
2026-06-08 03:56:42 +03:00
a454e4239f feat(archive): add retro theme support to archive browser
Implement retro-themed styling and classic pixelated icons for the
archive browser when the "retro" theme is active.

Changes include:
- Adding CSS overrides for `[data-theme="retro"]` to style the archive
  browser container, tree nodes, and hover states.
- Updating the JS preview script to dynamically append retro image
  icons (e.g., classic shell32/zipfldr icons) alongside SVGs.
- Toggling visibility between SVG and retro image icons using CSS
  based on the active theme.
2026-06-08 03:50:14 +03:00
cba416b238 feat(preview): add archive listing and browser support
Introduces the ability to browse and preview the contents of archive files directly within the web interface.

Changes include:
- Added a new API endpoint `GET /d/{boxID}/archive/{fileID}` to fetch archive listings.
- Implemented on-demand archive listing generation in the backend.
- Updated the frontend preview component to support rendering and navigating archive contents.
2026-06-08 03:43:43 +03:00
f9755fa98f feat(backend): add video scene preview generation and endpoint
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m52s
- Register a new route `GET /d/{boxID}/scene/{fileID}` to serve video scene previews.
- Implement the `VideoScenesPreview` handler to serve existing previews or generate them on-demand.
- Add helper functions to analyze video frames (e.g., luma calculation to filter out dark frames) and render the final scene thumbnail.
- Update the `fileView` struct to include scene URL and status fields.
2026-06-05 10:42:30 +03:00
2eba04b9da fix(upload): sniff content type for application/octet-stream
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m51s
When an incoming file has an empty content type or is marked as
"application/octet-stream", attempt to detect the actual MIME type
by reading the first 512 bytes of the file. This improves content
type accuracy for generic binary uploads.
2026-06-03 15:31:18 +03:00
81f4ce5e36 fix(handlers): support thumbnail rendering for files needing thumbnails
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 45s
Update `HasThumbnail` in `fileViewWithReactions` to evaluate to true if the file already has a thumbnail or if it is a file type that requires one (`jobs.NeedsThumbnail`). This ensures the download page renders the thumbnail element for files that are pending thumbnail generation or support dynamic thumbnails.

Additionally, update tests in `upload_stage3_test.go` to verify the thumbnail image is rendered and relax the OG image URL matching.
2026-06-03 15:22:58 +03:00
eff831b142 feat(backend): implement on-demand thumbnail generation
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 44s
When a thumbnail is requested but not yet available, attempt to generate
it synchronously on-demand instead of immediately falling back to the
placeholder image.

- Export thumbnail generation helpers from the jobs package.
- Update the Thumbnail handler to trigger on-demand generation if the
  thumbnail object is missing.
- Save the updated box metadata with the new thumbnail reference.
- Fall back to the placeholder image only if on-demand generation fails.
2026-06-03 15:20:26 +03:00
3b278642dc feat(backend): enhance social previews for single-file shares
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 42s
Implements dynamic Open Graph (OG) metadata and image generation for
single-file shared boxes to improve social media previews.

Changes include:
- Added a new route `/d/{boxID}/f/{fileID}/og-image.jpg` for file-specific OG images.
- Updated `DownloadPage` to dynamically set the page title, description, and OG image properties when a box contains only one file.
- Restricted raw media inline serving for social bots to images and videos.
- Added helper functions to format file share descriptions and determine appropriate social image URLs and types.
- Integrated basic font rendering to support dynamic OG image generation.
2026-06-03 14:55:19 +03:00
3a0dd04e61 feat(preview): add file preview page with metadata and styling
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m48s
Implement a rich file preview interface to allow users to view file
contents directly in the browser.

Changes include:
- Exposing raw file size (`SizeBytes`) in the download handler's file view.
- Adding comprehensive CSS styling for the preview layout and cards.
- Integrating Prism.js for syntax highlighting of code files.
- Updating Content Security Policy (CSP) headers to permit inline styles and frame sources required by the preview components.
- Adding unit tests to ensure preview metadata attributes are correctly rendered on the download page.
2026-06-03 14:28:50 +03:00
e17c5e92a7 feat(seo): add robots.txt, sitemap, and noindex tags for downloads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m2s
Register routes for robots.txt and sitemap.xml, and implement search engine indexing controls to protect user privacy.

Specifically:
- Set `X-Robots-Tag: noindex, nofollow, noarchive` headers on file downloads, thumbnails, and zip generation.
- Configure `Robots: web.RobotsNone` on download and preview pages to prevent indexing of temporary user uploads.
- Add canonical URLs, improved descriptions, and image alt tags to page metadata for better social sharing.
2026-06-03 12:15:49 +03:00
f698ba516d feat(upload): add transfer rate tracking and 6-hour expiry option
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m47s
- Implement real-time transfer rate tracking and display upload speed (e.g., Mb/s) in the progress status.
- Add a 6-hour (360 minutes) option to the upload expiry selection ladder.
- Fix an issue where the "new upload" button remained visible by explicitly toggling its display style and adding a CSS fallback for the `hidden` attribute.
2026-06-02 22:41:59 +03:00
17c31be8b4 chore(test): Fixed test
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m47s
2026-06-02 22:28:35 +03:00
313c89483c feat(upload): add resumable chunk configuration and file validation
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 56s
- Add `WARPBOX_RESUMABLE_CHUNK_MODE` and `WARPBOX_RESUMABLE_CHUNK_PATH` environment variables to configure temporary chunk storage.
- Implement strict file validation for resuming uploads to ensure selected files match the pending session's metadata.
- Add `PLANS.md` to document development stages, roadmap, and API specifications (including batching and resumable flows).
2026-06-02 22:13:54 +03:00
5cd476e7f3 feat(uploads): add native resumable upload support
Implement a native chunked resumable upload API and frontend integration
to support reliable large file uploads.

Changes include:
- Added a 3-step resumable upload API flow (create session, upload chunks, complete session).
- Introduced configuration options for chunk size, retention hours, and toggling the feature.
- Updated the frontend to utilize resumable uploads with progress tracking.
- Configured temporary chunk storage under `data/tmp/uploads` with automatic cleanup.
- Documented the API flow and configuration in the README.
2026-06-02 17:41:41 +03:00
d3b6a86753 feat(file-browser): make entire file card clickable in list view
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m45s
Improve the user experience in the file browser list view by allowing users to click anywhere on a file card to open it, rather than just the specific link.

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

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

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

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

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

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

Changes include:
- Updating the HTML templates to pass the height as a CSS variable.
- Converting the `.bar-chart` layout from Flexbox to CSS Grid for more consistent column distribution.
- Using absolute positioning for `.bar-chart-bar` inside `.bar-chart-track`.
- Adding a Go test to verify that the dashboard renders the CSS variable and no longer uses inline height styles.
2026-06-01 12:01:39 +03:00
48722f0aab refactor(backend/handlers): use withRequestLogAttrs helper for logging
Replace manual IP logging using `uploadClientIP(r)` with the
`withRequestLogAttrs` helper function in `manage.go`. This simplifies
the log statements and standardizes the extraction of request-related
attributes.
2026-06-01 11:46:34 +03:00
94cf9531fa refactor(handlers): standardize logging using request attributes helper
- Replace manual IP logging with the `withRequestLogAttrs` helper in authentication handlers.
- Add user activity logging for API documentation and login page views.
- Clean up log calls to use variadic expansion of request attributes.
2026-06-01 11:30:38 +03:00
115 changed files with 14464 additions and 737 deletions

View File

@@ -9,6 +9,11 @@ WARPBOX_CLEANUP_ENABLED=true
WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_ENABLED=true
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_ANONYMOUS_UPLOADS_ENABLED=true
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
@@ -27,7 +32,8 @@ WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
WARPBOX_IDLE_TIMEOUT=120s
WARPBOX_TRUSTED_PROXIES=

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ scripts/env/dev.env
docker-compose.yml
.claude
docs/possible_new_features

111
PLANS.md Normal file
View 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
View File

@@ -1,6 +1,20 @@
# 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
@@ -10,11 +24,25 @@ This repository contains the Go backend base for `warpbox.dev`, a self-hosted tr
The default server listens on `:8080`.
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
For one off Go commands, run them from the backend module:
Upload policy defaults are also configured in megabytes and can later be changed from
`/admin/settings`:
```bash
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_MAX_UPLOAD_MB=512`
@@ -33,35 +61,184 @@ Upload policy defaults are also configured in megabytes and can later be changed
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
- `WARPBOX_ANONYMOUS_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.
The dev script resolves that path from the repository root.
### Background jobs
Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs can be toggled with
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`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
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
links from `/admin/users`.
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
at `/admin/login` if you need to recover access without a user session.
The env admin token exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it at
`/admin/login` if you need to recover access without a user session.
For one-off Go commands, run them from the backend module:
## 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
cd backend
go run ./cmd/warpbox
# 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
```
## 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
`WARPBOX_ADMIN_TOKEN` before running the container:
Copy the example [docker-compose.example.yml](./docker-compose.example.yml) to [docker-compose.yml](./docker-compose.yml), modify as need-be
`WARPBOX_ADMIN_TOKEN` before running the container. Copy the example
[docker-compose.example.yml](./docker-compose.example.yml) to
[docker-compose.yml](./docker-compose.yml), modify as need-be:
```bash
cp .env.example .env
@@ -69,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
`./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use
`/data`, `/app/static`, and `/app/templates`.
`./data:/data:Z` for SELinux relabeling, and the container overrides runtime paths to use `/data`,
`/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
use `/health`.
## Reverse Proxy Security
### Reverse proxy security
Warpbox uses the resolved client IP for anonymous limits, manual bans, and automatic bans. The
default behavior trusts `X-Forwarded-For` and `X-Real-IP` so a normal Caddy reverse proxy works
without extra setup. For hardened deployments where the app port might be reachable from more than
one network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
without extra setup. For hardened deployments where the app port might be reachable from more than one
network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes.
## Systemd
### Systemd
Build the binary on the server, create a dedicated user, and keep runtime data outside the repo:
@@ -106,6 +281,9 @@ WARPBOX_DATA_DIR=/var/lib/warpbox
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
```
Example `/etc/systemd/system/warpbox.service`:
@@ -142,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.
## 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/libs/config` - environment-backed configuration.
@@ -150,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/jobs` - background job registration and job loop definitions.
- `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/web` - Go template renderer.
- `backend/templates` - server-rendered Go templates.
@@ -159,92 +353,13 @@ Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the p
- `scripts/env/dev.env.example` - tracked development environment template.
- `scripts/env/dev.env` - local development environment, ignored by git.
## Stage 2 Operator Tools
## Static asset policy
- `/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`.
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.
## Stage 3 Anonymous Integrations
## AI Usage
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.
I have used AI to accelerate development, all of the code has been reviewed by humans. I have mostly used self-hosted models as well as big models from big companies for a monthly subscription fee.
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.
I have nothing against AI as long as you can tell me what every single line of your code does. That's how I personally view things.

View File

@@ -54,6 +54,24 @@ network edge, or set it to a value that does not include public clients. Direct
public exposure is not recommended; use a reverse proxy for TLS and request
normalization.
## Large Uploads
Multi-GB uploads must not use whole-body read/write deadlines. Keep these
Warpbox values for production unless you intentionally want a hard wall-clock
upload limit:
```env
WARPBOX_READ_HEADER_TIMEOUT=15s
WARPBOX_READ_TIMEOUT=0s
WARPBOX_WRITE_TIMEOUT=0s
```
`WARPBOX_READ_HEADER_TIMEOUT` protects request headers. `WARPBOX_READ_TIMEOUT`
and `WARPBOX_WRITE_TIMEOUT` cover the whole upload/response lifetime in Go, so
small values can cause browser errors such as `NS_ERROR_NET_INTERRUPT` during
large transfers. Upload size, daily, storage, and box limits still enforce abuse
controls independently of these timeout values.
## Ban Behavior
Active bans return:

View File

@@ -11,26 +11,30 @@ import (
)
type Config struct {
AppName string
AppVersion string
Environment string
Addr string
BaseURL string
DataDir string
AdminToken string
StaticDir string
TemplateDir string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
TrustedProxies []string
JobsEnabled bool
CleanupEnabled bool
CleanupEvery time.Duration
ThumbnailEnabled bool
ThumbnailEvery time.Duration
MaxUploadSize int64
DefaultSettings SettingsDefaults
AppName string
AppVersion string
Environment string
Addr string
BaseURL string
DataDir string
AdminToken string
StaticDir string
TemplateDir string
ReadHeaderTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
TrustedProxies []string
JobsEnabled bool
CleanupEnabled bool
CleanupEvery time.Duration
ThumbnailEnabled bool
ThumbnailEvery time.Duration
ResumableUploadsEnabled bool
ResumableChunkSize int64
ResumableRetention time.Duration
MaxUploadSize int64
DefaultSettings SettingsDefaults
}
type SettingsDefaults struct {
@@ -51,29 +55,38 @@ type SettingsDefaults struct {
ShortWindowSeconds int
AnonymousStorageBackend string
UserStorageBackend string
ResumableUploadsEnabled bool
ResumableChunkSizeMB float64
ResumableRetentionHours int
ResumableChunkMode string
ResumableChunkPath string
}
func Load() (Config, error) {
cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
AppVersion: envString("APP_VERSION", "dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
AppVersion: envString("APP_VERSION", "dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
DataDir: envString("WARPBOX_DATA_DIR", defaultPath("data")),
AdminToken: envString("WARPBOX_ADMIN_TOKEN", ""),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadHeaderTimeout: envDuration("WARPBOX_READ_HEADER_TIMEOUT", 15*time.Second),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 0),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 0),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
ResumableUploadsEnabled: envBool("WARPBOX_RESUMABLE_UPLOADS_ENABLED", true),
ResumableChunkSize: envMegabytes("WARPBOX_RESUMABLE_CHUNK_MB", 8),
ResumableRetention: time.Duration(envInt("WARPBOX_RESUMABLE_RETENTION_HOURS", 24)) * time.Hour,
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
DefaultSettings: SettingsDefaults{
AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true),
AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512),
@@ -92,8 +105,13 @@ func Load() (Config, error) {
ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60),
AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_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 == "" {
return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty")
@@ -101,6 +119,12 @@ func Load() (Config, error) {
if cfg.MaxUploadSize <= 0 {
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) ||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) ||
!validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) ||

View File

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

View File

@@ -707,21 +707,94 @@ func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
for i, bar := range overview.UploadDays {
want := 0
if i == len(overview.UploadDays)-1 {
want = 100
want = 150
}
if bar.Height != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
if bar.HeightPx != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
for i, bar := range overview.StorageDays {
want := 0
if i == len(overview.StorageDays)-1 {
want = 100
want = 150
}
if bar.Height != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
if bar.HeightPx != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want)
}
}
if overview.StatusBars[0].WidthPercent != 100 {
t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent)
}
}
func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
yesterday := today.AddDate(0, 0, -1)
twoDaysAgo := today.AddDate(0, 0, -2)
boxes := []services.AdminBox{
{ID: "today-1", CreatedAt: today, TotalSize: 30},
{ID: "today-2", CreatedAt: today, TotalSize: 30},
{ID: "today-3", CreatedAt: today, TotalSize: 30},
{ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20},
{ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20},
{ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10},
}
overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1})
last := len(overview.UploadDays) - 1
if overview.UploadDays[last].HeightPx != 150 {
t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx)
}
if overview.UploadDays[last-1].HeightPx != 100 {
t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx)
}
if overview.UploadDays[last-2].HeightPx != 50 {
t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx)
}
if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 {
t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx)
}
if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 {
t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent)
}
}
func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
uploadThroughApp(t, app)
request := httptest.NewRequest(http.MethodGet, "/admin", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
response := httptest.NewRecorder()
app.AdminDashboard(response, request)
if response.Code != http.StatusOK {
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `style="height: 150px"`) {
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
}
if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)
}
if !strings.Contains(body, `style="height: 0px"`) {
t.Fatalf("admin overview did not render zero pixel bars: %s", body)
}
if !strings.Contains(body, `style="width: 100%"`) {
t.Fatalf("admin overview did not render a full-width status bar: %s", body)
}
if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) {
t.Fatalf("admin overview did not render status fallback data attributes: %s", body)
}
if strings.Contains(body, "--bar-height") {
t.Fatalf("admin overview still uses css variable bar heights: %s", body)
}
if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") {
t.Fatalf("admin overview did not load chart fallback script: %s", body)
}
}
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
@@ -1016,6 +1089,7 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
lines := strings.Join([]string{
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
`{"date":"2026-05-31","time":"12:36:56","source":"http","severity":"dev","code":200,"log":"http request","method":"GET","path":"/health","ip":"127.0.0.1","user_agent":"Wget"}`,
"",
}, "\n")
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
@@ -1036,6 +1110,16 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
if strings.Contains(logsBody, "172.30.0.1:48358") {
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
}
healthRequest := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
healthRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
healthResponse := httptest.NewRecorder()
app.AdminLogs(healthResponse, healthRequest)
if healthResponse.Code != http.StatusOK {
t.Fatalf("AdminLogs health status = %d, body = %s", healthResponse.Code, healthResponse.Body.String())
}
if strings.Contains(healthResponse.Body.String(), "/health") || strings.Contains(healthResponse.Body.String(), "Wget") {
t.Fatalf("AdminLogs rendered container health ping: %s", healthResponse.Body.String())
}
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})

View File

@@ -159,15 +159,17 @@ type adminOverview struct {
}
type adminChartBar struct {
Label string
Value string
Height int // 0-100, percent of the tallest bar
Label string
Value string
HeightPx int
RawValue int64
}
type adminStatBar struct {
Label string
Value string
Percent int
Label string
Value string
RawValue int
WidthPercent int
}
type adminBoxView struct {
@@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie
// status distributions for the overview dashboard.
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
const days = 14
const chartMaxHeightPx = 150
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
@@ -374,14 +377,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
storageDays := make([]adminChartBar, days)
for i := 0; i < days; i++ {
uploadDays[i] = adminChartBar{
Label: labels[i],
Value: strconv.Itoa(counts[i]),
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
Label: labels[i],
Value: strconv.Itoa(counts[i]),
HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
RawValue: int64(counts[i]),
}
storageDays[i] = adminChartBar{
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
Height: scaleHeight(bytes[i], maxBytes),
Label: labels[i],
Value: helpers.FormatBytes(bytes[i]),
HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
RawValue: bytes[i],
}
}
@@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
if activeBoxes < 0 {
activeBoxes = 0
}
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
statusBars := []adminStatBar{
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
{Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
}
return adminOverview{
@@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
}
}
func scaleHeight(value, max int64) int {
func scaleHeightPx(value, max int64, maxHeightPx int) int {
if max <= 0 || value <= 0 {
return 0
}
height := int(value * 100 / max)
if height < 4 {
height = 4
height := int(value * int64(maxHeightPx) / max)
if height < 8 {
height = 8
}
if height > maxHeightPx {
return maxHeightPx
}
return height
}
@@ -420,6 +429,16 @@ func percentOf(value, total int) int {
return value * 100 / total
}
func maxInt(values ...int) int {
max := 0
for _, value := range values {
if value > max {
max = value
}
}
return max
}
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
@@ -555,6 +574,7 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
return
}
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 {
settings.UsageRetentionDays = value
}
@@ -585,6 +605,16 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
if value := parsePositiveInt(r.FormValue("short_window_seconds")); value > 0 {
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 != "" {
settings.AnonymousStorageBackend = value
}
@@ -1731,11 +1761,29 @@ func readLogEntries(file string) ([]adminLogEntry, error) {
if err := json.Unmarshal(line, &raw); err != nil {
continue
}
if isHealthCheckLogEntry(raw) {
continue
}
entries = append(entries, logEntryFromMap(raw))
}
return entries, scanner.Err()
}
func isHealthCheckLogEntry(raw map[string]any) bool {
path := strings.TrimSpace(firstLogString(raw, "path", "route"))
if path == "" {
return false
}
fields := strings.Fields(path)
if len(fields) > 0 {
path = fields[len(fields)-1]
}
if idx := strings.IndexByte(path, '?'); idx >= 0 {
path = path[:idx]
}
return path == "/health"
}
func logEntryFromMap(raw map[string]any) adminLogEntry {
entry := adminLogEntry{
Date: logString(raw, "date"),

View File

@@ -10,7 +10,6 @@ import (
type apiDocsData struct {
BaseURL string
UploadURL string
HealthURL string
RequestSchemaURL string
ResponseSchemaURL string
ShareXExamplePath string
@@ -21,13 +20,24 @@ type apiDocsData struct {
}
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
user, loggedIn := a.currentUser(r)
actor := "anonymous"
if loggedIn {
actor = "user"
}
a.logger.Info("api docs viewed", withRequestLogAttrs(r,
"source", "page",
"severity", "user_activity",
"code", 2501,
"actor", actor,
"user_id", user.ID,
)...)
a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
Title: "API documentation",
Description: "Curl and ShareX upload examples for Warpbox.",
Data: apiDocsData{
BaseURL: a.cfg.BaseURL,
UploadURL: a.cfg.BaseURL + "/api/v1/upload",
HealthURL: a.cfg.BaseURL + "/api/v1/health",
RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json",
ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json",
ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu",

View File

@@ -16,12 +16,18 @@ type App struct {
uploadService *services.UploadService
authService *services.AuthService
settingsService *services.SettingsService
reactionService *services.ReactionService
banService *services.BanService
rateLimiter *rateLimiter
uploadGroups *uploadGrouper
fileIcons *fileIconSet
}
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, 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{
cfg: cfg,
logger: logger,
@@ -29,9 +35,11 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService,
authService: authService,
settingsService: settingsService,
reactionService: reactionService,
banService: banService,
rateLimiter: newRateLimiter(),
uploadGroups: newUploadGrouper(),
fileIcons: fileIcons,
}
}
@@ -46,6 +54,8 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs)
mux.HandleFunc("GET /service-worker.js", a.ServiceWorker)
mux.HandleFunc("POST /share-target", a.ShareTargetFallback)
mux.HandleFunc("GET /register", a.Register)
mux.HandleFunc("POST /register", a.RegisterPost)
mux.HandleFunc("GET /login", a.Login)
@@ -121,16 +131,34 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
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}/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}/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 /robots.txt", a.RobotsTxt)
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
mux.HandleFunc("GET /health", a.Health)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("GET /healthz", notFound)
mux.HandleFunc("GET /api/v1/health", notFound)
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-response.json", a.UploadResponseSchema)
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())
}
func notFound(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}

View File

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

View File

@@ -42,6 +42,12 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
a.logger.Info("user dashboard viewed", withRequestLogAttrs(r,
"source", "page",
"severity", "user_activity",
"code", 2502,
"user_id", user.ID,
)...)
collections, err := a.authService.ListCollections(user.ID)
if err != nil {
http.Error(w, "unable to load collections", http.StatusInternalServerError)
@@ -112,9 +118,9 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
a.logger.Warn("collection create failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())...)
} else {
a.logger.Info("collection created", "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))
a.logger.Info("collection created", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))...)
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
@@ -129,11 +135,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
a.logger.Warn("owned box rename failed", "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
a.logger.Warn("owned box rename failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
a.handleUserBoxError(w, r, err)
return
}
a.logger.Info("owned box renamed", "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))
a.logger.Info("owned box renamed", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
@@ -148,16 +154,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
}
collectionID := r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
a.logger.Warn("owned box move invalid collection", "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
a.logger.Warn("owned box move invalid collection", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
http.Error(w, "collection not found", http.StatusForbidden)
return
}
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
a.logger.Warn("owned box move failed", "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
a.logger.Warn("owned box move failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
a.handleUserBoxError(w, r, err)
return
}
a.logger.Info("owned box moved", "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
a.logger.Info("owned box moved", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)...)
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
@@ -167,11 +173,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
a.logger.Warn("owned box delete failed", "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
a.logger.Warn("owned box delete failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())...)
a.handleUserBoxError(w, r, err)
return
}
a.logger.Info("owned box deleted", "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))
a.logger.Info("owned box deleted", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))...)
http.Redirect(w, r, "/app", http.StatusSeeOther)
}

View File

@@ -2,16 +2,20 @@ package handlers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
@@ -26,6 +30,7 @@ type downloadPageData struct {
DownloadCount int
MaxDownloads int
ExpiresLabel string
EmojiTabs []emojiTabView
}
type boxView struct {
@@ -36,11 +41,46 @@ type fileView struct {
ID string
Name string
Size string
SizeBytes int64
ContentType string
PreviewKind string
URL string
DownloadURL string
ThumbnailURL string
SceneURL string
ArchiveURL string
HasThumbnail bool
HasScene bool
HasArchive bool
IconURL string
IconRetroURL string
ReactURL string
Reactions []reactionView
ReactionMore int
Reacted bool
Processing bool
Failed bool
Error string
}
type reactionView struct {
EmojiID string `json:"emojiId"`
URL string `json:"url"`
Label string `json:"label"`
Count int `json:"count"`
Visible bool `json:"visible"`
}
type emojiTabView struct {
ID string
Label string
Emojis []emojiOptionView
}
type emojiOptionView struct {
ID string `json:"id"`
URL string `json:"url"`
Label string `json:"label"`
}
type previewPageData struct {
@@ -53,12 +93,12 @@ type previewPageData struct {
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
a.logger.Warn("download page missing box", "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
a.logger.Warn("download page missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"))...)
http.NotFound(w, r)
return
}
if err := a.uploadService.CanDownload(box); err != nil {
a.logger.Warn("download page unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
a.logger.Warn("download page unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
Title: "Download unavailable",
Description: "This Warpbox link is no longer available.",
@@ -70,26 +110,69 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return
}
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))
if !(locked && box.Obfuscate) {
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")
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 {
title = "Protected Warpbox link"
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{
Title: title,
Description: description,
ImageURL: absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID)),
Title: title,
Description: description,
CanonicalURL: pageURL,
Robots: robots,
ImageURL: ogImage,
ImageAlt: imageAlt,
ImageType: imageType,
Data: downloadPageData{
Box: boxView{ID: box.ID},
Files: files,
@@ -99,9 +182,10 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
ExpiresLabel: expiresLabel,
EmojiTabs: emojiTabs,
},
})
a.logger.Info("download page viewed", "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "ip", uploadClientIP(r), "locked", locked)
a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...)
}
func plural(n int) string {
@@ -111,6 +195,43 @@ func plural(n int) string {
return "s"
}
func shouldServeRawSocialMedia(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video"
}
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
if strings.TrimSpace(contentType) == "" {
contentType = "file"
}
return fmt.Sprintf("%s %s. Open to preview or download. Expires %s.", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
}
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
if file.PreviewKind == "image" {
return absoluteURL(r, view.DownloadURL+"?inline=1")
}
if file.PreviewKind == "video" && view.HasThumbnail {
return absoluteURL(r, view.ThumbnailURL)
}
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
}
func socialImageType(file services.File) string {
if file.PreviewKind == "image" {
return file.ContentType
}
return "image/jpeg"
}
func socialOGType(file services.File) string {
switch file.PreviewKind {
case "video":
return "video.other"
default:
return "website"
}
}
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
@@ -118,20 +239,70 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
}
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
if isSocialPreviewBot(r) && !locked {
if file.Processing {
http.Error(w, "file is still processing", http.StatusAccepted)
return
}
if file.ProcessingError != "" {
a.logger.Warn("failed file preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4241, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
if services.BoxHasTrouble(box) {
a.logger.Warn("failed box preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4245, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
if shouldServeRawSocialMedia(file) {
a.serveFileContent(w, r, box, file, false)
a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
return
}
}
if file.ProcessingError != "" && !locked {
a.logger.Warn("failed file preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4242, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
if services.BoxHasTrouble(box) && !locked {
a.logger.Warn("failed box preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4246, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size)
title := file.Name
description := fmt.Sprintf("%s shared via Warpbox", helpers.FormatBytes(file.Size))
imageURL := absoluteURL(r, view.ThumbnailURL)
description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
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 {
title = "Protected Warpbox file"
description = "This shared file is password protected."
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{
Title: title,
Description: description,
ImageURL: imageURL,
Title: title,
Description: description,
CanonicalURL: pageURL,
Robots: web.RobotsNone,
OGType: ogType,
ImageURL: imageURL,
ImageAlt: imageAlt,
ImageType: socialImageType(file),
MediaURL: mediaURL,
MediaType: file.ContentType,
Data: previewPageData{
Box: boxView{ID: box.ID},
File: view,
@@ -139,25 +310,41 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
DownloadURL: view.DownloadURL,
},
})
a.logger.Info("file preview page viewed", "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
a.logger.Info("file preview page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID)...)
}
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
a.logger.Warn("protected file download blocked", "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
a.logger.Warn("protected file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID)...)
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if file.Processing {
http.Error(w, "file is still processing", http.StatusAccepted)
return
}
if file.ProcessingError != "" {
a.logger.Warn("failed file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4243, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
if services.BoxHasTrouble(box) {
a.logger.Warn("failed box download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4247, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
a.logger.Info("file content served", "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "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) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
@@ -166,9 +353,25 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
a.servePlaceholderThumbnail(w, r)
return
}
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
a.logger.Warn("thumbnail request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4110, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
a.servePlaceholderThumbnail(w, r)
return
}
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
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
// placeholder but mark it non-cacheable, otherwise the browser would
// keep showing the placeholder until a hard refresh once the real
@@ -183,6 +386,178 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if !jobs.NeedsVideoScenes(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
a.servePlaceholderThumbnail(w, r)
return
}
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
a.servePlaceholderThumbnail(w, r)
return
}
object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
if err != nil {
if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" {
file.SceneThumbnail = scene
object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
return
}
}
a.servePlaceholderThumbnail(w, r)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if !jobs.NeedsArchiveListing(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency)
return
}
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
return
}
}
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return ""
}
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
if err != nil || thumbnail == "" {
if err != nil {
a.logger.Warn("on-demand thumbnail generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4102, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].Thumbnail = thumbnail
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand thumbnail metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4103, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return thumbnail
}
func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string {
if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return ""
}
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file)
if err != nil || scene == "" {
if err != nil {
a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].SceneThumbnail = scene
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return scene
}
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return ""
}
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
if err != nil || listing == "" {
if err != nil {
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].ArchiveListing = listing
box.Files[i].ArchiveListingObjectKey = ""
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return listing
}
func troubleReasonForLog(box services.Box, file services.File) string {
if services.FileHasTrouble(file) {
return file.ProcessingError
}
return services.BoxTroubleReason(box)
}
// servePlaceholderThumbnail serves the fallback image with no-store so the
// browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated.
@@ -202,7 +577,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
return
}
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID, "ip", uploadClientIP(r))
a.logger.Warn("box unlock failed", withRequestLogAttrs(r, "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)...)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
return
}
@@ -215,26 +590,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
Secure: r.TLS != nil,
Expires: box.ExpiresAt,
})
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID, "ip", uploadClientIP(r))
a.logger.Info("box unlocked", withRequestLogAttrs(r, "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)...)
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
}
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
a.logger.Warn("file request missing box", "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
a.logger.Warn("file request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"))...)
http.NotFound(w, r)
return services.Box{}, services.File{}, false
}
if err := a.uploadService.CanDownload(box); err != nil {
a.logger.Warn("file request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r), "error", err.Error())
a.logger.Warn("file request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "error", err.Error())...)
http.Error(w, err.Error(), statusForDownloadError(err))
return services.Box{}, services.File{}, false
}
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
if err != nil {
a.logger.Warn("file request missing file", "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
a.logger.Warn("file request missing file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"))...)
http.NotFound(w, r)
return services.Box{}, services.File{}, false
}
@@ -244,16 +619,18 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
if err != nil {
a.logger.Warn("file object missing", "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "error", err.Error())
a.logger.Warn("file object missing", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
http.NotFound(w, r)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType)
disposition := "inline"
if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
disposition = "attachment"
}
w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name))
if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else {
@@ -269,6 +646,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
}
}
func contentDisposition(disposition, name string) string {
filename := cleanDownloadFilename(name)
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename))
}
func cleanDownloadFilename(name string) string {
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
clean = filepath.Base(clean)
if clean == "" || clean == "." || clean == "/" {
return "download"
}
return clean
}
func asciiFilenameFallback(name string) string {
var fallback strings.Builder
for _, char := range name {
switch {
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';':
fallback.WriteByte('_')
case char <= 0x7e:
fallback.WriteRune(char)
default:
fallback.WriteByte('_')
}
}
clean := strings.TrimSpace(fallback.String())
if clean == "" {
return "download"
}
return clean
}
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
data, err := io.ReadAll(source)
if err != nil {
@@ -278,25 +688,42 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
}
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"))
if err != nil {
a.logger.Warn("zip request missing box", "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
a.logger.Warn("zip request missing box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"))...)
http.NotFound(w, r)
return
}
if err := a.uploadService.CanDownload(box); err != nil {
a.logger.Warn("zip request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
a.logger.Warn("zip request unavailable", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "error", err.Error())...)
http.Error(w, err.Error(), statusForDownloadError(err))
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
a.logger.Warn("protected zip download blocked", "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID, "ip", uploadClientIP(r))
a.logger.Warn("protected zip download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID)...)
http.Error(w, "password required", http.StatusUnauthorized)
return
}
for _, file := range box.Files {
if file.Processing {
http.Error(w, "file is still processing", http.StatusAccepted)
return
}
if file.ProcessingError != "" {
a.logger.Warn("zip download blocked by failed file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4244, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
}
if services.BoxHasTrouble(box) {
a.logger.Warn("zip download blocked by failed box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4248, "box_id", box.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip"))
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if err := a.uploadService.WriteZip(w, box); err != nil {
@@ -306,22 +733,210 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
}
a.logger.Info("zip downloaded", "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "ip", uploadClientIP(r), "files", len(box.Files))
a.logger.Info("zip downloaded", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "files", len(box.Files))...)
}
func (a *App) fileView(box services.Box, file services.File) fileView {
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{
ID: file.ID,
Name: file.Name,
Size: helpers.FormatBytes(file.Size),
SizeBytes: file.Size,
ContentType: file.ContentType,
PreviewKind: file.PreviewKind,
URL: fmt.Sprintf("/d/%s/f/%s", 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),
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
HasThumbnail: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)),
HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)),
HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)),
IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
Reactions: reactionViews,
ReactionMore: reactionOverflowCount(reactionViews),
Reacted: reacted,
Processing: file.Processing,
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
Error: troubleReasonForLog(box, file),
}
}
func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid reaction", http.StatusBadRequest)
return
}
emojiID := strings.TrimSpace(r.FormValue("emoji_id"))
if !a.validEmojiID(emojiID) {
http.Error(w, "unknown emoji", http.StatusBadRequest)
return
}
visitorID := a.reactionVisitorID(w, r)
reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID)
if errors.Is(err, os.ErrExist) {
writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"})
return
}
if err != nil {
a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
http.Error(w, "could not save reaction", http.StatusInternalServerError)
return
}
a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...)
writeJSON(w, http.StatusCreated, map[string]any{
"reactions": a.reactionViews(reactions),
"reacted": true,
})
}
func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView {
views := make([]reactionView, 0, len(reactions))
for index, reaction := range reactions {
views = append(views, reactionView{
EmojiID: reaction.EmojiID,
URL: emojiURL(reaction.EmojiID),
Label: emojiLabel(reaction.EmojiID),
Count: reaction.Count,
Visible: index < 2,
})
}
return views
}
func reactionOverflowCount(reactions []reactionView) int {
if len(reactions) <= 2 {
return 0
}
return len(reactions) - 2
}
func (a *App) emojiTabs() ([]emojiTabView, error) {
root := a.emojiRoot()
entries, err := os.ReadDir(root)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
tabs := make([]emojiTabView, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
tabID := entry.Name()
files, err := os.ReadDir(filepath.Join(root, tabID))
if err != nil {
return nil, err
}
tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)}
for _, file := range files {
if file.IsDir() || !isEmojiFile(file.Name()) {
continue
}
emojiID := tabID + "/" + file.Name()
tab.Emojis = append(tab.Emojis, emojiOptionView{
ID: emojiID,
URL: emojiURL(emojiID),
Label: emojiLabel(emojiID),
})
}
sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID })
if len(tab.Emojis) > 0 {
tabs = append(tabs, tab)
}
}
sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
return tabs, nil
}
func (a *App) validEmojiID(id string) bool {
id = strings.TrimSpace(id)
if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") {
return false
}
parts := strings.Split(id, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) {
return false
}
info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1]))
return err == nil && !info.IsDir()
}
func (a *App) emojiRoot() string {
return filepath.Join(a.cfg.DataDir, "emoji")
}
func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string {
const cookieName = "warpbox_reactor"
if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" {
return cookie.Value
}
visitorID := services.RandomPublicToken(32)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: visitorID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().AddDate(1, 0, 0),
})
return visitorID
}
func isEmojiFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif"
}
func emojiTabLabel(id string) string {
label := strings.NewReplacer("-", " ", "_", " ").Replace(id)
if label == "" {
return "Emoji"
}
return strings.ToUpper(label[:1]) + label[1:]
}
func emojiLabel(id string) string {
base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id))
return strings.ReplaceAll(base, "-", " ")
}
func emojiURL(id string) string {
parts := strings.Split(id, "/")
if len(parts) != 2 {
return ""
}
return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1])
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool {
if !a.uploadService.IsProtected(box) {
return true
@@ -362,3 +977,31 @@ func absoluteURL(r *http.Request, path string) string {
}
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
}

View File

@@ -13,6 +13,10 @@ type healthResponse struct {
}
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{
Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339),

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
Description: "Delete this anonymous Warpbox upload.",
Data: a.managePageData(box, r.PathValue("token")),
})
a.logger.Info("anonymous manage page viewed", "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID, "ip", uploadClientIP(r))
a.logger.Info("anonymous manage page viewed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID)...)
}
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
@@ -41,11 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
}
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
a.logger.Warn("anonymous delete failed", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())...)
http.NotFound(w, r)
return
}
a.logger.Info("anonymous box deleted", "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID, "ip", uploadClientIP(r))
a.logger.Info("anonymous box deleted", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID)...)
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
}
@@ -60,12 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
a.logger.Warn("anonymous manage missing box", "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
a.logger.Warn("anonymous manage missing box", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"))...)
http.NotFound(w, r)
return services.Box{}, false
}
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
a.logger.Warn("anonymous manage invalid token", "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID, "ip", uploadClientIP(r))
a.logger.Warn("anonymous manage invalid token", withRequestLogAttrs(r, "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID)...)
http.NotFound(w, r)
return services.Box{}, false
}

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

View File

@@ -2,6 +2,8 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
"image/draw"
@@ -11,10 +13,19 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"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"
_ "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
@@ -74,6 +85,77 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
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) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
@@ -115,6 +197,326 @@ func (a *App) ogPlaceholder() image.Image {
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.
func renderCollage(thumbs []image.Image) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))

View File

@@ -10,6 +10,7 @@ import (
type homeData struct {
MaxUploadSize string
MaxUploadBytes int64
LimitSummary string
Collections []collectionView
IsAdmin bool
@@ -46,14 +47,29 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
return
}
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
actor := "anonymous"
if loggedIn {
actor = "user"
}
a.logger.Info("upload page viewed", withRequestLogAttrs(r,
"source", "page",
"severity", "user_activity",
"code", 2500,
"actor", actor,
"user_id", user.ID,
)...)
maxUploadSize, maxUploadBytes, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.",
CurrentUser: currentUser,
Title: "Upload your files",
Description: "Upload and share files quickly. Drop a file, get a link.",
CanonicalURL: absoluteURL(r, "/"),
ImageURL: absoluteURL(r, "/static/og-default.png"),
ImageAlt: "Warp Box | simple file sharing and fast downloads",
CurrentUser: currentUser,
Data: homeData{
MaxUploadSize: maxUploadSize,
MaxUploadBytes: maxUploadBytes,
LimitSummary: limitSummary,
Collections: collections,
IsAdmin: isAdmin,
@@ -84,7 +100,7 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser
}
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
if unlimited || capMinutes <= 0 {
@@ -141,22 +157,25 @@ func expiryLabel(minutes int) string {
}
}
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, int64, string) {
if isAdmin {
return "No file size limit", "Admin uploads bypass storage and daily caps."
return "No file size limit", -1, "Admin uploads bypass storage and daily caps."
}
if !loggedIn {
if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files."
return "Anonymous uploads disabled", 0, "Sign in to upload files."
}
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), services.MegabytesToBytes(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
}
policy := a.settingsService.EffectivePolicyForUser(settings, user)
maxUpload := a.uploadService.MaxUploadSizeLabel()
maxUploadBytes := a.uploadService.MaxUploadSize()
if policy.MaxUploadMB < 0 {
maxUpload = "unlimited"
maxUploadBytes = -1
} else if policy.MaxUploadMB > 0 {
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
maxUploadBytes = services.MegabytesToBytes(policy.MaxUploadMB)
}
quota := "unlimited"
if policy.StorageQuotaSet {
@@ -166,5 +185,5 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if policy.MaxDays < 0 {
expiryLimit = "no expiry limit."
}
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
return maxUpload, maxUploadBytes, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
}

View File

@@ -0,0 +1,438 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
)
type resumableCreateRequest struct {
Files []services.ResumableFileInput `json:"files"`
MaxDays int `json:"maxDays"`
ExpiresMinutes int `json:"expiresMinutes"`
MaxDownloads int `json:"maxDownloads"`
Password string `json:"password"`
ObfuscateMetadata bool `json:"obfuscateMetadata"`
CollectionID string `json:"collectionId"`
}
type resumableSessionResponse struct {
SessionID string `json:"sessionId"`
ResumeToken string `json:"resumeToken,omitempty"`
ChunkSize int64 `json:"chunkSize"`
Status string `json:"status"`
BoxID string `json:"boxId,omitempty"`
ExpiresAt string `json:"expiresAt"`
Files []services.ResumableFile `json:"files"`
}
func (a *App) CreateResumableUpload(w http.ResponseWriter, r *http.Request) {
user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil {
a.logger.Warn("resumable upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4011)...)
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return
}
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
if !settings.ResumableUploadsEnabled {
helpers.WriteJSONError(w, http.StatusForbidden, "resumable uploads are disabled")
return
}
if !loggedIn && !settings.AnonymousUploadsEnabled {
a.logger.Warn("resumable anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4013)...)
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && policy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, policy.ShortRequests, policy.ShortWindow, time.Now().UTC()) {
a.logger.Warn("resumable upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4291, "user_id", user.ID)...)
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return
}
var payload resumableCreateRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "upload session request could not be read")
return
}
fileSizes := make([]int64, 0, len(payload.Files))
var totalBytes int64
for _, file := range payload.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
a.logger.Warn("resumable upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(payload.Files))...)
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
a.logger.Warn("resumable upload rejected by box policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(payload.Files))...)
helpers.WriteJSONError(w, status, message)
return
}
}
opts, err := a.resumableUploadOptions(r, payload, user, loggedIn, isAdminUpload, policy)
if err != nil {
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
return
}
chunkSize := int64(settings.ResumableChunkSizeMB * 1024 * 1024)
retention := time.Duration(settings.ResumableRetentionHours) * time.Hour
session, err := a.uploadService.CreateResumableSession(payload.Files, opts, chunkSize, retention, resumableChunkRoot(settings))
if err != nil {
a.logger.Warn("resumable session create failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4002, "user_id", user.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload session created", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2002, "user_id", user.ID, "session_id", session.ID, "files", len(session.Files), "bytes", totalBytes, "anonymous", !loggedIn)...)
helpers.WriteJSON(w, http.StatusCreated, resumableResponse(session))
}
func (a *App) ResumableUploadStatus(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
helpers.WriteJSON(w, http.StatusOK, resumableResponse(session))
}
func (a *App) AddResumableFiles(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
var payload struct {
Files []services.ResumableFileInput `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "upload files request could not be read")
return
}
fileSizes := make([]int64, 0, len(session.Files)+len(payload.Files))
var totalBytes int64
for _, file := range session.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
for _, file := range payload.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
updated, err := a.uploadService.AddResumableFiles(session.ID, payload.Files)
if err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload files added", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2006, "session_id", session.ID, "added", len(updated.Files)-len(session.Files), "files", len(updated.Files))...)
helpers.WriteJSON(w, http.StatusOK, resumableResponse(updated))
}
func (a *App) PutResumableChunk(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
fileID := r.PathValue("fileID")
index, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "chunk index is invalid")
return
}
updated, err := a.uploadService.PutResumableChunk(r.Context(), session.ID, fileID, index, r.Body)
if err != nil {
a.logger.Warn("resumable chunk failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4003, "session_id", session.ID, "file_id", fileID, "chunk", index, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable chunk uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2003, "session_id", session.ID, "file_id", fileID, "chunk", index)...)
helpers.WriteJSON(w, http.StatusOK, resumableResponse(updated))
}
func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
if session.Status == services.ResumableStatusCompleted {
result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID)
if err != nil {
a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files))...)
helpers.WriteJSON(w, http.StatusOK, result)
return
}
if session.Status == services.ResumableStatusProcessing {
result, err := a.uploadService.FinalizeProcessingResumableSession(r.Context(), session.ID)
if err != nil {
a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", session.ID, "box_id", result.BoxID, "files", len(result.Files))...)
helpers.WriteJSON(w, http.StatusOK, result)
return
}
user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
fileSizes := make([]int64, 0, len(session.Files))
var totalBytes int64
for _, file := range session.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkStorageBackendCapacity(session.Options.StorageBackendID, settings, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
result, completed, err := a.uploadService.CreateProcessingBoxFromResumable(session.ID)
if err != nil {
a.logger.Warn("resumable upload complete failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
a.logger.Warn("failed to record resumable upload usage", "source", "quota", "severity", "warn", "code", 4404, "error", err.Error())
}
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4405, "error", err.Error())
}
}
a.finalizeResumableUploadAsync(completed.ID, result.BoxID)
a.logger.Info("resumable upload queued for processing", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "user_id", user.ID, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
helpers.WriteJSON(w, http.StatusCreated, result)
}
func (a *App) CompleteUploadedResumableUpload(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
fileSizes := make([]int64, 0, len(session.Files))
var totalBytes int64
var completeCount int
for _, file := range session.Files {
if len(file.UploadedChunks) != file.ChunkCount {
continue
}
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
completeCount++
}
if completeCount == 0 {
helpers.WriteJSONError(w, http.StatusBadRequest, "no fully uploaded files to finish")
return
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkStorageBackendCapacity(session.Options.StorageBackendID, settings, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
result, completed, err := a.uploadService.CompleteUploadedResumableSession(r.Context(), session.ID)
if err != nil {
a.logger.Warn("resumable partial complete failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4005, "session_id", session.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
a.logger.Warn("failed to record partial resumable upload usage", "source", "quota", "severity", "warn", "code", 4406, "error", err.Error())
}
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4405, "error", err.Error())
}
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
a.logger.Info("resumable uploaded files completed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2007, "user_id", user.ID, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
helpers.WriteJSON(w, http.StatusCreated, result)
}
func (a *App) finalizeResumableUploadAsync(sessionID, boxID string) {
go func() {
a.logger.Info("resumable upload processing started", "source", "user-upload", "severity", "user_activity", "code", 2009, "session_id", sessionID, "box_id", boxID)
result, err := a.uploadService.FinalizeProcessingResumableSession(context.Background(), sessionID)
if err != nil {
a.logger.Warn("resumable upload processing failed", "source", "user-upload", "severity", "warn", "code", 4010, "session_id", sessionID, "box_id", boxID, "error", err.Error())
return
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
a.logger.Info("resumable upload processing completed", "source", "user-upload", "severity", "user_activity", "code", 2010, "session_id", sessionID, "box_id", result.BoxID, "files", len(result.Files))
}()
}
func resumableChunkRoot(settings services.UploadPolicySettings) string {
if settings.ResumableChunkMode != "custom" {
return ""
}
return strings.TrimSpace(settings.ResumableChunkPath)
}
func (a *App) CancelResumableUpload(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
if err := a.uploadService.CancelResumableSession(session.ID); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload cancelled", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2005, "session_id", session.ID)...)
w.WriteHeader(http.StatusNoContent)
}
func (a *App) authorizedResumableSession(w http.ResponseWriter, r *http.Request) (services.ResumableSession, bool) {
user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil {
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return services.ResumableSession{}, false
}
session, err := a.uploadService.GetResumableSession(r.PathValue("sessionID"))
if err != nil {
helpers.WriteJSONError(w, http.StatusNotFound, "upload session not found")
return services.ResumableSession{}, false
}
if !a.uploadService.VerifyResumableToken(session, r.Header.Get("X-Warpbox-Resume-Token")) {
helpers.WriteJSONError(w, http.StatusUnauthorized, "upload session not found")
return services.ResumableSession{}, false
}
if loggedIn {
if session.Options.OwnerID != user.ID {
helpers.WriteJSONError(w, http.StatusForbidden, "upload session not found")
return services.ResumableSession{}, false
}
return session, true
}
if session.Options.OwnerID != "" || session.Options.CreatorIP != uploadClientIP(r) {
helpers.WriteJSONError(w, http.StatusForbidden, "upload session not found")
return services.ResumableSession{}, false
}
return session, true
}
func (a *App) loadUploadPolicyForAPI(w http.ResponseWriter, r *http.Request, user services.User, loggedIn bool) (services.UploadPolicySettings, services.EffectiveUploadPolicy, bool) {
settings, err := a.settingsService.UploadPolicy()
if err != nil {
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5006, "error", err.Error())
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
return services.UploadPolicySettings{}, services.EffectiveUploadPolicy{}, false
}
return settings, a.effectiveUploadPolicy(settings, user, loggedIn), true
}
func (a *App) resumableUploadOptions(r *http.Request, payload resumableCreateRequest, user services.User, loggedIn, isAdminUpload bool, policy services.EffectiveUploadPolicy) (services.UploadOptions, error) {
var ownerID string
var collectionID string
if loggedIn {
ownerID = user.ID
collectionID = strings.TrimSpace(payload.CollectionID)
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
return services.UploadOptions{}, fmt.Errorf("collection not found")
}
}
unlimitedExpiry := isAdminUpload || policy.MaxDays < 0
rawMaxDays := payload.MaxDays
maxDays := rawMaxDays
if maxDays <= 0 {
maxDays = 7
if policy.MaxDays > 0 && policy.MaxDays < maxDays {
maxDays = policy.MaxDays
}
}
expiresMinutes := payload.ExpiresMinutes
if expiresMinutes < 0 || rawMaxDays < 0 {
if !unlimitedExpiry {
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
}
expiresMinutes = -1
} else if !unlimitedExpiry {
if maxDays > policy.MaxDays {
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
}
if expiresMinutes > 0 && expiresMinutes > policy.MaxDays*24*60 {
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
}
}
return services.UploadOptions{
MaxDays: maxDays,
ExpiresInMinutes: expiresMinutes,
MaxDownloads: payload.MaxDownloads,
Password: payload.Password,
ObfuscateMetadata: payload.ObfuscateMetadata,
OwnerID: ownerID,
CollectionID: collectionID,
SkipSizeLimit: isAdminUpload || policy.MaxUploadMB < 0,
CreatorIP: uploadClientIP(r),
StorageBackendID: policy.StorageBackendID,
}, nil
}
func resumableResponse(session services.ResumableSession) resumableSessionResponse {
return resumableSessionResponse{
SessionID: session.ID,
ResumeToken: session.ResumeToken,
ChunkSize: session.ChunkSize,
Status: session.Status,
BoxID: session.BoxID,
ExpiresAt: session.ExpiresAt.Format(time.RFC3339),
Files: session.Files,
}
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"os"
"path/filepath"
"strings"
)
@@ -15,6 +16,35 @@ func (a *App) Static() http.Handler {
})
}
func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
pack := strings.TrimSpace(r.PathValue("pack"))
file := strings.TrimSpace(r.PathValue("file"))
if pack == "" || file == "" || strings.Contains(pack, "/") || strings.Contains(pack, "\\") || strings.Contains(pack, "..") || strings.Contains(file, "/") || strings.Contains(file, "\\") || strings.Contains(file, "..") || !isEmojiFile(file) {
http.NotFound(w, r)
return
}
path := filepath.Join(a.emojiRoot(), pack, file)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
setStaticCacheHeaders(w, r.URL.Path)
http.ServeFile(w, r, path)
}
func (a *App) ServiceWorker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Service-Worker-Allowed", "/")
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "js", "service-worker.js"))
}
func (a *App) ShareTargetFallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/?share-target=unsupported", http.StatusSeeOther)
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path))

View File

@@ -1,7 +1,11 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
@@ -24,3 +28,76 @@ func TestSetStaticCacheHeaders(t *testing.T) {
}
}
}
func TestWebManifestIncludesShareTarget(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest"))
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
var manifest struct {
ShareTarget struct {
Action string `json:"action"`
Method string `json:"method"`
EncType string `json:"enctype"`
Params struct {
Title string `json:"title"`
Text string `json:"text"`
URL string `json:"url"`
Files []struct {
Name string `json:"name"`
Accept []string `json:"accept"`
} `json:"files"`
} `json:"params"`
} `json:"share_target"`
}
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if manifest.ShareTarget.Action != "/share-target" || manifest.ShareTarget.Method != "POST" || manifest.ShareTarget.EncType != "multipart/form-data" {
t.Fatalf("unexpected share_target config: %+v", manifest.ShareTarget)
}
if manifest.ShareTarget.Params.Title != "title" || manifest.ShareTarget.Params.Text != "text" || manifest.ShareTarget.Params.URL != "url" {
t.Fatalf("unexpected share_target params: %+v", manifest.ShareTarget.Params)
}
if len(manifest.ShareTarget.Params.Files) != 1 || manifest.ShareTarget.Params.Files[0].Name != "files" || len(manifest.ShareTarget.Params.Files[0].Accept) != 1 || manifest.ShareTarget.Params.Files[0].Accept[0] != "*/*" {
t.Fatalf("unexpected share_target files: %+v", manifest.ShareTarget.Params.Files)
}
}
func TestServiceWorkerServedFromRootScope(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/service-worker.js", nil)
response := httptest.NewRecorder()
app.ServiceWorker(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if got := response.Header().Get("Service-Worker-Allowed"); got != "/" {
t.Fatalf("Service-Worker-Allowed = %q, want /", got)
}
if got := response.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if response.Body.Len() == 0 {
t.Fatalf("service worker body missing")
}
}
func TestShareTargetFallbackRedirectsHome(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodPost, "/share-target", nil)
response := httptest.NewRecorder()
app.ShareTargetFallback(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther)
}
if got := response.Header().Get("Location"); got != "/?share-target=unsupported" {
t.Fatalf("Location = %q", got)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
@@ -18,7 +19,7 @@ import (
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil {
a.logger.Warn("upload rejected invalid bearer token", "source", "user-upload", "severity", "warn", "code", 4010, "ip", uploadClientIP(r), "user_agent", r.UserAgent())
a.logger.Warn("upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4010)...)
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return
}
@@ -30,14 +31,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
if !loggedIn && !settings.AnonymousUploadsEnabled {
a.logger.Warn("anonymous upload rejected disabled", "source", "user-upload", "severity", "warn", "code", 4012, "ip", uploadClientIP(r))
a.logger.Warn("anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4012)...)
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID)
a.logger.Warn("upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4290, "user_id", user.ID)...)
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return
}
@@ -52,12 +53,17 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
parseLimit = 32 << 20
}
if err := r.ParseMultipartForm(parseLimit); err != nil {
a.logger.Warn("upload form parse failed", "source", "user-upload", "severity", "warn", "code", 4000, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, "upload exceeds the configured upload limit")
return
}
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return
}
files := uploadFiles(r)
files := uploadIncomingFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID string
var collectionID string
@@ -65,14 +71,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
ownerID = user.ID
collectionID = r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
a.logger.Warn("upload rejected invalid collection", "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)
a.logger.Warn("upload rejected invalid collection", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)...)
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
return
}
}
if !isAdminUpload {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))...)
helpers.WriteJSONError(w, status, message)
return
}
@@ -89,7 +95,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
}
if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays {
a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)
a.logger.Warn("upload rejected expiration days", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4131, "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)...)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return
}
@@ -99,13 +105,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
// Only honour it for unlimited uploaders; otherwise it's an invalid value.
if expiresMinutes < 0 || rawMaxDays < 0 {
if !unlimitedExpiry {
a.logger.Warn("upload rejected unlimited expiration", "source", "user-upload", "severity", "warn", "code", 4133, "ip", uploadClientIP(r), "user_id", user.ID)
a.logger.Warn("upload rejected unlimited expiration", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4133, "user_id", user.ID)...)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return
}
expiresMinutes = -1
} else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 {
a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)
a.logger.Warn("upload rejected expiration minutes", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4132, "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)...)
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return
}
@@ -123,12 +129,12 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
result, boxesAdded, status, policyMessage, err := a.createOrAppendBox(r, user, loggedIn, effectivePolicy, files, opts, !isAdminUpload)
if policyMessage != "" {
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))
a.logger.Warn("upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", policyMessage, "bytes", totalBytes, "files", len(files))...)
helpers.WriteJSONError(w, status, policyMessage)
return
}
if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
a.logger.Warn("upload failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4001, "user_id", user.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
@@ -141,7 +147,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
a.logger.Info("upload response sent", "source", "user-upload", "severity", "user_activity", "code", 2001, "ip", uploadClientIP(r), "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload)
a.logger.Info("box uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2001, "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
if wantsJSON(r) {
helpers.WriteJSON(w, http.StatusCreated, result)
@@ -159,7 +165,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
// uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []services.IncomingFile, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
if batch == "" {
if enforceBoxLimits {
@@ -167,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
@@ -188,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
if result, err := a.uploadService.AppendIncomingFiles(entry.boxID, files, opts); err == nil {
// Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL.
result.ManageURL = entry.manageURL
@@ -204,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil
}
}
result, err := a.uploadService.CreateBox(files, opts)
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil {
return services.UploadResult{}, 0, 0, "", err
}
@@ -224,16 +230,27 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []services.IncomingFile, totalBytes int64) (int, string) {
if len(files) == 0 {
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()
if policy.MaxUploadMB > 0 {
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, file := range files {
if file.Size > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
for _, fileSize := range fileSizes {
if fileSize > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB)
}
}
}
@@ -367,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
return "ip:" + uploadClientIP(r)
}
func totalUploadBytes(files []*multipart.FileHeader) int64 {
func totalUploadBytes(files []services.IncomingFile) int64 {
var total int64
for _, file := range files {
total += file.Size
total += file.Size()
}
return total
}
@@ -393,13 +410,48 @@ func statusForDownloadError(err error) int {
return http.StatusForbidden
}
func uploadFiles(r *http.Request) []*multipart.FileHeader {
type namedMultipartFile struct {
header *multipart.FileHeader
name string
}
func (f namedMultipartFile) Name() string {
if strings.TrimSpace(f.name) != "" {
return f.name
}
return f.header.Filename
}
func (f namedMultipartFile) Size() int64 {
return f.header.Size
}
func (f namedMultipartFile) ContentType() string {
return f.header.Header.Get("Content-Type")
}
func (f namedMultipartFile) Open() (io.ReadCloser, error) {
return f.header.Open()
}
func uploadIncomingFiles(r *http.Request) []services.IncomingFile {
if r.MultipartForm == nil {
return nil
}
files := make([]*multipart.FileHeader, 0)
files = append(files, r.MultipartForm.File["file"]...)
files = append(files, r.MultipartForm.File["sharex"]...)
fileHeaders := r.MultipartForm.File["file"]
shareXHeaders := r.MultipartForm.File["sharex"]
paths := r.MultipartForm.Value["file_path"]
files := make([]services.IncomingFile, 0, len(fileHeaders)+len(shareXHeaders))
for index, header := range fileHeaders {
name := ""
if index < len(paths) {
name = paths[index]
}
files = append(files, namedMultipartFile{header: header, name: name})
}
for _, header := range shareXHeaders {
files = append(files, namedMultipartFile{header: header})
}
return files
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
@@ -10,10 +11,13 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
@@ -46,6 +50,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) {
app, cleanup := newTestApp(t)
defer cleanup()
@@ -67,6 +107,545 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
}
}
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
request.Header.Set("User-Agent", "Discordbot/2.0")
request.SetPathValue("boxID", payload.BoxID)
response := httptest.NewRecorder()
app.DownloadPage(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
}
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
t.Fatalf("download page did not render text thumbnail image: %s", body)
}
if !strings.Contains(body, "Open to preview or download") {
t.Fatalf("social preview body missing preview/download description: %s", body)
}
}
func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
request.Header.Set("User-Agent", "TelegramBot")
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFile(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
}
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
t.Fatalf("social preview body missing twitter card metadata: %s", body)
}
}
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
request.Header.Set("Accept", "application/json")
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, request)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
previewRequest.SetPathValue("boxID", payload.BoxID)
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFile(response, previewRequest)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if strings.Contains(response.Body.String(), "preview-title") {
t.Fatalf("image social preview bot received HTML preview page")
}
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
t.Fatalf("image social preview body = %q", response.Body.String())
}
}
func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFile(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`data-size-bytes="5"`,
`data-source-url="/d/` + payload.BoxID,
`data-download-url="/d/` + payload.BoxID,
`data-icon-url="/static/file-icons/`,
`data-preview-tabs`,
} {
if !strings.Contains(body, want) {
t.Fatalf("preview page missing %q: %s", want, body)
}
}
}
func TestDownloadPageShowsProcessingFailure(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
box.Files[0].Processing = false
box.Files[0].ProcessingError = "Access Denied."
if err := app.uploadService.SaveBox(box); err != nil {
t.Fatalf("SaveBox returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
request.SetPathValue("boxID", payload.BoxID)
response := httptest.NewRecorder()
app.DownloadPage(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
"Upload processing failed",
"Access Denied.",
"is-failed",
"Failed",
} {
if !strings.Contains(body, want) {
t.Fatalf("download page missing %q: %s", want, body)
}
}
if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) {
t.Fatalf("failed file still exposed download context: %s", body)
}
}
func TestFileDownloadUsesOriginalFilename(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello")
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil)
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFileContent(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
disposition := response.Header().Get("Content-Disposition")
for _, want := range []string{
`attachment;`,
`filename="report final.txt"`,
`filename*=UTF-8''report%20final.txt`,
} {
if !strings.Contains(disposition, want) {
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
}
}
if response.Body.String() != "hello" {
t.Fatalf("body = %q", response.Body.String())
}
}
func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello")
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil)
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFileContent(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
disposition := response.Header().Get("Content-Disposition")
for _, want := range []string{
`inline;`,
`filename="r_sum_ 2026.txt"`,
`filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`,
} {
if !strings.Contains(disposition, want) {
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
}
}
}
func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createBody := `{"files":[{"name":"note.txt","size":11,"contentType":"text/plain"}],"expiresMinutes":60}`
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
createRequest.Header.Set("Accept", "application/json")
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
ResumeToken string `json:"resumeToken"`
ChunkSize int64 `json:"chunkSize"`
Files []struct {
ID string `json:"id"`
ChunkCount int `json:"chunkCount"`
UploadedChunks []int `json:"uploadedChunks"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
if session.SessionID == "" || session.ResumeToken == "" || session.ChunkSize != 4 || len(session.Files) != 1 || session.Files[0].ChunkCount != 3 {
t.Fatalf("unexpected session response: %+v", session)
}
chunks := map[int]string{1: "o wo", 0: "hell", 2: "rld"}
for index, body := range chunks {
request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/"+strconv.Itoa(index), strings.NewReader(body))
request.SetPathValue("sessionID", session.SessionID)
request.SetPathValue("fileID", session.Files[0].ID)
request.SetPathValue("index", strconv.Itoa(index))
request.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
response := httptest.NewRecorder()
app.PutResumableChunk(response, request)
if response.Code != http.StatusOK {
t.Fatalf("chunk %d status = %d, body = %s", index, response.Code, response.Body.String())
}
}
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
completeRequest.SetPathValue("sessionID", session.SessionID)
completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
completeResponse := httptest.NewRecorder()
app.CompleteResumableUpload(completeResponse, completeRequest)
if completeResponse.Code != http.StatusCreated {
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal result returned error: %v", err)
}
replayRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
replayRequest.SetPathValue("sessionID", session.SessionID)
replayRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
replayResponse := httptest.NewRecorder()
app.CompleteResumableUpload(replayResponse, replayRequest)
if replayResponse.Code != http.StatusOK {
t.Fatalf("complete replay status = %d, body = %s", replayResponse.Code, replayResponse.Body.String())
}
var replayPayload services.UploadResult
if err := json.Unmarshal(replayResponse.Body.Bytes(), &replayPayload); err != nil {
t.Fatalf("json.Unmarshal replay result returned error: %v", err)
}
if replayPayload.BoxID != payload.BoxID || replayPayload.BoxURL == "" {
t.Fatalf("unexpected replay result: %+v, original: %+v", replayPayload, payload)
}
box := waitForProcessedBox(t, app, payload.BoxID)
if len(box.Files) != 1 || box.Files[0].Name != "note.txt" || box.Files[0].Size != 11 {
t.Fatalf("unexpected box files: %+v", box.Files)
}
object, err := app.uploadService.OpenFileObject(context.Background(), box, box.Files[0])
if err != nil {
t.Fatalf("OpenFileObject returned error: %v", err)
}
data, err := io.ReadAll(object.Body)
object.Body.Close()
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if string(data) != "hello world" {
t.Fatalf("uploaded body = %q", string(data))
}
}
func TestResumableUploadRequiresAllChunks(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":8,"contentType":"text/plain"}]}`))
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
ResumeToken string `json:"resumeToken"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
chunkRequest := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("hell"))
chunkRequest.SetPathValue("sessionID", session.SessionID)
chunkRequest.SetPathValue("fileID", session.Files[0].ID)
chunkRequest.SetPathValue("index", "0")
chunkRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
chunkResponse := httptest.NewRecorder()
app.PutResumableChunk(chunkResponse, chunkRequest)
if chunkResponse.Code != http.StatusOK {
t.Fatalf("chunk status = %d, body = %s", chunkResponse.Code, chunkResponse.Body.String())
}
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
completeRequest.SetPathValue("sessionID", session.SessionID)
completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
completeResponse := httptest.NewRecorder()
app.CompleteResumableUpload(completeResponse, completeRequest)
if completeResponse.Code != http.StatusBadRequest {
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
}
func TestResumableStatusRequiresResumeToken(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":4,"contentType":"text/plain"}]}`))
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
ResumeToken string `json:"resumeToken"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
missing := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
missing.SetPathValue("sessionID", session.SessionID)
missingResponse := httptest.NewRecorder()
app.ResumableUploadStatus(missingResponse, missing)
if missingResponse.Code != http.StatusUnauthorized {
t.Fatalf("missing token status = %d, body = %s", missingResponse.Code, missingResponse.Body.String())
}
wrong := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
wrong.SetPathValue("sessionID", session.SessionID)
wrong.Header.Set("X-Warpbox-Resume-Token", "wrong")
wrongResponse := httptest.NewRecorder()
app.ResumableUploadStatus(wrongResponse, wrong)
if wrongResponse.Code != http.StatusUnauthorized {
t.Fatalf("wrong token status = %d, body = %s", wrongResponse.Code, wrongResponse.Body.String())
}
valid := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
valid.SetPathValue("sessionID", session.SessionID)
valid.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
validResponse := httptest.NewRecorder()
app.ResumableUploadStatus(validResponse, valid)
if validResponse.Code != http.StatusOK {
t.Fatalf("valid token status = %d, body = %s", validResponse.Code, validResponse.Body.String())
}
if strings.Contains(validResponse.Body.String(), "resumeTokenHash") {
t.Fatalf("status response leaked token hash: %s", validResponse.Body.String())
}
}
func TestResumableUploadCanAddFilesToExistingSession(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createBody := `{"files":[{"name":"one.txt","size":4,"contentType":"text/plain","fingerprint":"one"}],"expiresMinutes":60}`
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
ResumeToken string `json:"resumeToken"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
firstChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("one!"))
firstChunk.SetPathValue("sessionID", session.SessionID)
firstChunk.SetPathValue("fileID", session.Files[0].ID)
firstChunk.SetPathValue("index", "0")
firstChunk.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
firstChunkResponse := httptest.NewRecorder()
app.PutResumableChunk(firstChunkResponse, firstChunk)
if firstChunkResponse.Code != http.StatusOK {
t.Fatalf("first chunk status = %d, body = %s", firstChunkResponse.Code, firstChunkResponse.Body.String())
}
addRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/files", strings.NewReader(`{"files":[{"name":"two.txt","size":4,"contentType":"text/plain","fingerprint":"two"}]}`))
addRequest.SetPathValue("sessionID", session.SessionID)
addRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
addResponse := httptest.NewRecorder()
app.AddResumableFiles(addResponse, addRequest)
if addResponse.Code != http.StatusOK {
t.Fatalf("add status = %d, body = %s", addResponse.Code, addResponse.Body.String())
}
var updated struct {
Files []struct {
ID string `json:"id"`
Name string `json:"name"`
UploadedChunks []int `json:"uploadedChunks"`
} `json:"files"`
}
if err := json.Unmarshal(addResponse.Body.Bytes(), &updated); err != nil {
t.Fatalf("json.Unmarshal updated returned error: %v", err)
}
if len(updated.Files) != 2 || len(updated.Files[0].UploadedChunks) != 1 {
t.Fatalf("unexpected updated session: %+v", updated)
}
secondChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+updated.Files[1].ID+"/chunks/0", strings.NewReader("two!"))
secondChunk.SetPathValue("sessionID", session.SessionID)
secondChunk.SetPathValue("fileID", updated.Files[1].ID)
secondChunk.SetPathValue("index", "0")
secondChunk.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
secondChunkResponse := httptest.NewRecorder()
app.PutResumableChunk(secondChunkResponse, secondChunk)
if secondChunkResponse.Code != http.StatusOK {
t.Fatalf("second chunk status = %d, body = %s", secondChunkResponse.Code, secondChunkResponse.Body.String())
}
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
completeRequest.SetPathValue("sessionID", session.SessionID)
completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
completeResponse := httptest.NewRecorder()
app.CompleteResumableUpload(completeResponse, completeRequest)
if completeResponse.Code != http.StatusCreated {
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal result returned error: %v", err)
}
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
if len(box.Files) != 2 {
t.Fatalf("box file count = %d, want 2", len(box.Files))
}
}
func TestResumableCompleteUploadedRequiresTokenAndKeepsFinishedFiles(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createBody := `{"files":[{"name":"done.txt","size":4,"contentType":"text/plain","fingerprint":"done"},{"name":"partial.txt","size":8,"contentType":"text/plain","fingerprint":"partial"}],"expiresMinutes":60}`
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
ResumeToken string `json:"resumeToken"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
for _, chunk := range []struct {
fileIndex int
index string
body string
}{
{fileIndex: 0, index: "0", body: "done"},
{fileIndex: 1, index: "0", body: "part"},
} {
request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[chunk.fileIndex].ID+"/chunks/"+chunk.index, strings.NewReader(chunk.body))
request.SetPathValue("sessionID", session.SessionID)
request.SetPathValue("fileID", session.Files[chunk.fileIndex].ID)
request.SetPathValue("index", chunk.index)
request.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
response := httptest.NewRecorder()
app.PutResumableChunk(response, request)
if response.Code != http.StatusOK {
t.Fatalf("chunk status = %d, body = %s", response.Code, response.Body.String())
}
}
missing := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete-uploaded", nil)
missing.SetPathValue("sessionID", session.SessionID)
missingResponse := httptest.NewRecorder()
app.CompleteUploadedResumableUpload(missingResponse, missing)
if missingResponse.Code != http.StatusUnauthorized {
t.Fatalf("missing token status = %d, body = %s", missingResponse.Code, missingResponse.Body.String())
}
complete := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete-uploaded", nil)
complete.SetPathValue("sessionID", session.SessionID)
complete.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
completeResponse := httptest.NewRecorder()
app.CompleteUploadedResumableUpload(completeResponse, complete)
if completeResponse.Code != http.StatusCreated {
t.Fatalf("complete-uploaded status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal result returned error: %v", err)
}
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
if len(box.Files) != 1 || box.Files[0].Name != "done.txt" {
t.Fatalf("complete-uploaded box files = %+v", box.Files)
}
if _, err := app.uploadService.GetResumableSession(session.SessionID); !os.IsNotExist(err) {
t.Fatalf("GetResumableSession after complete-uploaded error = %v, want os.ErrNotExist", err)
}
}
func TestManageBoxAndDeleteFlow(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
@@ -178,13 +757,16 @@ func newTestApp(t *testing.T) (*App, func()) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{
AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024,
AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024,
ResumableUploadsEnabled: true,
ResumableChunkSize: 4,
ResumableRetention: time.Hour,
DefaultSettings: config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 1,
@@ -192,12 +774,24 @@ func newTestApp(t *testing.T) (*App, func()) {
UserDailyUploadMB: 8,
DefaultUserStorageMB: 16,
UsageRetentionDays: 30,
ResumableUploadsEnabled: true,
ResumableChunkSizeMB: 0.000003814697265625,
ResumableRetentionHours: 1,
ResumableChunkMode: "same",
},
}
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
if err != nil {
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)
if err != nil {
service.Close()
@@ -213,12 +807,18 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close()
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())
if err != nil {
service.Close()
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() {
jobs.WaitForThumbnailJobs()
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
@@ -226,8 +826,12 @@ func newTestApp(t *testing.T) (*App, func()) {
}
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
}
func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
t.Helper()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
@@ -293,6 +897,31 @@ func tokenFromURL(t *testing.T, value string) string {
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 {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {

View File

@@ -32,13 +32,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
uploadService.Close()
return nil, err
}
reactionService, err := services.NewReactionService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
banService, err := services.NewBanService(uploadService.DB())
if err != nil {
uploadService.Close()
return nil, err
}
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, reactionService, banService)
router := http.NewServeMux()
app.RegisterRoutes(router)
@@ -50,16 +55,16 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
middleware.SecurityHeaders,
middleware.Gzip,
middleware.ClientIP(cfg.TrustedProxies),
middleware.Logger(logger),
middleware.Bans(logger, banService, cfg.TrustedProxies),
)
server := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}
server.RegisterOnShutdown(func() {
stopJobs()

View File

@@ -22,6 +22,14 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
if cleaned > 0 {
logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
}
cleanedUploads, err := uploadService.CleanupExpiredResumableSessions(time.Now().UTC())
if err != nil {
logger.Warn("resumable upload cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4204, "error", err.Error())
return
}
if cleanedUploads > 0 {
logger.Info("resumable uploads cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2204, "cleaned", cleanedUploads)
}
if banService != nil {
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
if err != nil {
@@ -37,7 +45,12 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
}
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) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,19 @@
package jobs
import (
"archive/zip"
"bytes"
"encoding/json"
"image"
"image/color"
"image/jpeg"
"image/png"
"io"
"log/slog"
"mime/multipart"
"net/http/httptest"
"net/textproto"
"strings"
"testing"
"warpbox.dev/backend/libs/services"
@@ -46,6 +50,151 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
}
}
func TestGenerateMissingThumbnailsForTroubleBoxSkipsWork(t *testing.T) {
service := newThumbnailTestUploadService(t)
result := createThumbnailTestBox(t, service)
box, err := service.GetBox(result.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
box.Trouble = true
box.TroubleReason = "storage backend failed"
if err := service.SaveBox(box); err != nil {
t.Fatalf("SaveBox returned error: %v", err)
}
jobResult, err := generateMissingThumbnailsForBox(service, slog.New(slog.NewTextHandler(io.Discard, nil)), box)
if err != nil {
t.Fatalf("generateMissingThumbnailsForBox returned error: %v", err)
}
if jobResult != (ThumbnailJobResult{}) {
t.Fatalf("job result = %+v, want no work for trouble box", jobResult)
}
updated, err := service.GetBox(result.BoxID)
if err != nil {
t.Fatalf("GetBox after job returned error: %v", err)
}
if updated.Files[0].Thumbnail != "" {
t.Fatalf("thumbnail was generated for trouble box: %+v", updated.Files[0])
}
}
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
data, err := createTextThumbnail(services.File{
Name: "notes.md",
ContentType: "text/markdown",
}, strings.NewReader("# Meeting notes\n\n```go\nfunc main() {}\n```\n\nA rendered Markdown preview."))
if err != nil {
t.Fatalf("createTextThumbnail returned error: %v", err)
}
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("jpeg.Decode returned error: %v", err)
}
if img.Bounds().Dx() != 360 || img.Bounds().Dy() != 240 {
t.Fatalf("thumbnail size = %dx%d, want 360x240", img.Bounds().Dx(), img.Bounds().Dy())
}
}
func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
if !needsThumbnail(services.File{Name: "main.go", ContentType: "text/plain"}) {
t.Fatalf("Go source file should get a text thumbnail")
}
}
func TestUsableVideoFrameRejectsBlackFrame(t *testing.T) {
var dark bytes.Buffer
if err := jpeg.Encode(&dark, solidTestImage(color.RGBA{A: 255}), nil); err != nil {
t.Fatalf("jpeg.Encode dark returned error: %v", err)
}
if usableVideoFrame(dark.Bytes()) {
t.Fatalf("black video frame should not be usable")
}
var bright bytes.Buffer
if err := jpeg.Encode(&bright, solidTestImage(color.RGBA{R: 180, G: 80, B: 40, A: 255}), nil); err != nil {
t.Fatalf("jpeg.Encode bright returned error: %v", err)
}
if !usableVideoFrame(bright.Bytes()) {
t.Fatalf("bright video frame should be usable")
}
}
func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) {
data := renderVideoScenesThumbnail(
services.File{Name: "clip.mp4", ContentType: "video/mp4"},
videoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 125, FrameRate: "24.00 fps"},
[]videoSceneFrame{
{Timestamp: "00:00:10", Image: solidTestImage(color.RGBA{R: 140, G: 40, B: 80, A: 255})},
{Timestamp: "00:00:35", Image: solidTestImage(color.RGBA{R: 40, G: 120, B: 150, A: 255})},
},
)
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("jpeg.Decode returned error: %v", err)
}
if img.Bounds().Dx() != 1200 || img.Bounds().Dy() != 630 {
t.Fatalf("scene preview size = %dx%d, want 1200x630", img.Bounds().Dx(), img.Bounds().Dy())
}
}
func TestCreateArchiveListingRendersZipTree(t *testing.T) {
var archive bytes.Buffer
writer := zip.NewWriter(&archive)
addZipTestFile(t, writer, "docs/readme.md", "hello")
addZipTestFile(t, writer, "src/main.go", "package main\n")
if err := writer.Close(); err != nil {
t.Fatalf("zip.Close returned error: %v", err)
}
data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes()))
if err != nil {
t.Fatalf("createArchiveListing returned error: %v", err)
}
var listing archiveListingData
if err := json.Unmarshal(data, &listing); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data))
}
if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 {
t.Fatalf("archive listing metadata = %+v", listing)
}
if listing.Root == nil || len(listing.Root.Items) != 2 {
t.Fatalf("archive listing root = %+v", listing.Root)
}
if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" {
t.Fatalf("first archive folder = %+v", listing.Root.Items[0])
}
if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" {
t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0])
}
if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" {
t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0])
}
}
func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) {
t.Helper()
file, err := writer.Create(name)
if err != nil {
t.Fatalf("zip.Create returned error: %v", err)
}
if _, err := file.Write([]byte(body)); err != nil {
t.Fatalf("zip file write returned error: %v", err)
}
}
func solidTestImage(c color.Color) image.Image {
img := image.NewRGBA(image.Rect(0, 0, 32, 24))
for y := 0; y < img.Bounds().Dy(); y++ {
for x := 0; x < img.Bounds().Dx(); x++ {
img.Set(x, y, c)
}
}
return img
}
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
t.Helper()
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))

View File

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

View File

@@ -9,7 +9,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
header.Set("X-Frame-Options", "DENY")
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
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)
})

View File

@@ -472,7 +472,7 @@ func (s *BanService) MaliciousPattern(path string) (string, error) {
}
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) {

View File

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

View File

@@ -0,0 +1,765 @@
package services
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
)
var resumableUploadsBucket = []byte("resumable_uploads")
const (
ResumableStatusUploading = "uploading"
ResumableStatusProcessing = "processing"
ResumableStatusCompleted = "completed"
ResumableStatusCancelled = "cancelled"
)
type ResumableFileInput struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
Fingerprint string `json:"fingerprint,omitempty"`
}
type ResumableSession struct {
ID string `json:"id"`
Options UploadOptions `json:"options"`
Files []ResumableFile `json:"files"`
ChunkSize int64 `json:"chunkSize"`
Status string `json:"status"`
BoxID string `json:"boxId,omitempty"`
ResumeTokenHash string `json:"resumeTokenHash,omitempty"`
ResumeToken string `json:"-"`
ChunkRoot string `json:"chunkRoot,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type ResumableFile struct {
ID string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
Fingerprint string `json:"fingerprint,omitempty"`
ChunkCount int `json:"chunkCount"`
UploadedChunks []int `json:"uploadedChunks"`
}
func (s *UploadService) ensureResumableBucket() error {
return s.db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(resumableUploadsBucket)
return err
})
}
func (s *UploadService) CreateResumableSession(files []ResumableFileInput, opts UploadOptions, chunkSize int64, retention time.Duration, chunkRoot string) (ResumableSession, error) {
if len(files) == 0 {
return ResumableSession{}, fmt.Errorf("no files were uploaded")
}
if chunkSize <= 0 {
return ResumableSession{}, fmt.Errorf("chunk size must be positive")
}
if retention <= 0 {
return ResumableSession{}, fmt.Errorf("retention must be positive")
}
if strings.TrimSpace(opts.Password) != "" {
opts.PasswordSalt, opts.PasswordHash = hashPassword(opts.Password)
opts.Password = ""
}
sessionFiles, err := s.resumableFilesFromInput(files, opts, chunkSize, nil)
if err != nil {
return ResumableSession{}, err
}
now := time.Now().UTC()
resumeToken := randomID(32)
sessionID := randomID(12)
session := ResumableSession{
ID: sessionID,
Options: opts,
Files: sessionFiles,
ChunkSize: chunkSize,
Status: ResumableStatusUploading,
ResumeTokenHash: resumableTokenHash(sessionID, resumeToken),
ResumeToken: resumeToken,
ChunkRoot: strings.TrimSpace(chunkRoot),
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(retention),
}
if err := s.saveResumableSession(session); err != nil {
return ResumableSession{}, err
}
return session, nil
}
func (s *UploadService) VerifyResumableToken(session ResumableSession, token string) bool {
if session.ResumeTokenHash == "" || strings.TrimSpace(token) == "" {
return false
}
hash := resumableTokenHash(session.ID, token)
return subtle.ConstantTimeCompare([]byte(hash), []byte(session.ResumeTokenHash)) == 1
}
func (s *UploadService) AddResumableFiles(sessionID string, files []ResumableFileInput) (ResumableSession, error) {
if len(files) == 0 {
return s.GetResumableSession(sessionID)
}
session, err := s.GetResumableSession(sessionID)
if err != nil {
return ResumableSession{}, err
}
if err := resumableSessionWritable(session); err != nil {
return ResumableSession{}, err
}
existing := make(map[string]bool)
for _, file := range session.Files {
existing[resumableFileKey(file.Name, file.Size, file.Fingerprint)] = true
}
newFiles, err := s.resumableFilesFromInput(files, session.Options, session.ChunkSize, existing)
if err != nil {
return ResumableSession{}, err
}
if len(newFiles) == 0 {
return session, nil
}
session.Files = append(session.Files, newFiles...)
session.UpdatedAt = time.Now().UTC()
if err := s.saveResumableSession(session); err != nil {
return ResumableSession{}, err
}
return session, nil
}
func (s *UploadService) GetResumableSession(id string) (ResumableSession, error) {
var session ResumableSession
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(resumableUploadsBucket)
if bucket == nil {
return os.ErrNotExist
}
data := bucket.Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &session)
})
if err != nil {
return ResumableSession{}, err
}
return session, nil
}
func (s *UploadService) PutResumableChunk(ctx context.Context, sessionID, fileID string, index int, body io.Reader) (ResumableSession, error) {
session, err := s.GetResumableSession(sessionID)
if err != nil {
return ResumableSession{}, err
}
if err := resumableSessionWritable(session); err != nil {
return ResumableSession{}, err
}
fileIndex := -1
for i, file := range session.Files {
if file.ID == fileID {
fileIndex = i
break
}
}
if fileIndex < 0 {
return ResumableSession{}, os.ErrNotExist
}
file := session.Files[fileIndex]
if index < 0 || index >= file.ChunkCount {
return ResumableSession{}, fmt.Errorf("chunk index is invalid")
}
expectedSize := expectedChunkSize(file.Size, session.ChunkSize, index)
chunkDir := s.resumableFileDirFor(session, file.ID)
if err := os.MkdirAll(chunkDir, 0o755); err != nil {
return ResumableSession{}, err
}
chunkPath := s.resumableChunkPathFor(session, file.ID, index)
tempPath := chunkPath + ".tmp"
target, err := os.OpenFile(tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return ResumableSession{}, err
}
written, copyErr := io.Copy(target, io.LimitReader(body, expectedSize+1))
closeErr := target.Close()
if copyErr != nil {
_ = os.Remove(tempPath)
return ResumableSession{}, copyErr
}
if closeErr != nil {
_ = os.Remove(tempPath)
return ResumableSession{}, closeErr
}
if written != expectedSize {
_ = os.Remove(tempPath)
return ResumableSession{}, fmt.Errorf("chunk size mismatch")
}
if err := os.Rename(tempPath, chunkPath); err != nil {
_ = os.Remove(tempPath)
return ResumableSession{}, err
}
session.Files[fileIndex].UploadedChunks = addChunkIndex(session.Files[fileIndex].UploadedChunks, index)
session.UpdatedAt = time.Now().UTC()
if err := s.saveResumableSession(session); err != nil {
return ResumableSession{}, err
}
return session, nil
}
func (s *UploadService) CompleteResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
session, err := s.GetResumableSession(sessionID)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
if (session.Status == ResumableStatusCompleted || session.Status == ResumableStatusProcessing) && session.BoxID != "" {
box, err := s.GetBox(session.BoxID)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
return s.resultForBox(box, ""), session, nil
}
if err := resumableSessionWritable(session); err != nil {
return UploadResult{}, ResumableSession{}, err
}
staged, err := s.resumableIncomingFiles(session)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
result, err := s.CreateBoxFromIncomingContext(ctx, staged, session.Options)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
return UploadResult{}, ResumableSession{}, err
}
session.Status = ResumableStatusCompleted
session.BoxID = result.BoxID
session.UpdatedAt = time.Now().UTC()
if err := s.saveResumableSession(session); err != nil {
return UploadResult{}, ResumableSession{}, err
}
return result, session, nil
}
func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (UploadResult, ResumableSession, error) {
session, err := s.GetResumableSession(sessionID)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
if (session.Status == ResumableStatusCompleted || session.Status == ResumableStatusProcessing) && session.BoxID != "" {
box, err := s.GetBox(session.BoxID)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
return s.resultForBox(box, ""), session, nil
}
if err := resumableSessionWritable(session); err != nil {
return UploadResult{}, ResumableSession{}, err
}
if _, err := s.resumableIncomingFiles(session); err != nil {
return UploadResult{}, ResumableSession{}, err
}
now := time.Now().UTC()
expiresAt := now.AddDate(0, 0, 7)
if session.Options.ExpiresInMinutes < 0 || session.Options.MaxDays < 0 {
expiresAt = now.AddDate(100, 0, 0)
} else if session.Options.ExpiresInMinutes > 0 {
expiresAt = now.Add(time.Duration(session.Options.ExpiresInMinutes) * time.Minute)
} else if session.Options.MaxDays > 0 {
expiresAt = now.Add(time.Duration(session.Options.MaxDays) * 24 * time.Hour)
}
box := Box{
ID: randomID(10),
OwnerID: strings.TrimSpace(session.Options.OwnerID),
CollectionID: strings.TrimSpace(session.Options.CollectionID),
CreatorIP: strings.TrimSpace(session.Options.CreatorIP),
StorageBackendID: normalizeBackendID(session.Options.StorageBackendID),
CreatedAt: now,
ExpiresAt: expiresAt,
MaxDownloads: session.Options.MaxDownloads,
Obfuscate: session.Options.ObfuscateMetadata && (strings.TrimSpace(session.Options.Password) != "" || strings.TrimSpace(session.Options.PasswordHash) != ""),
Files: make([]File, 0, len(session.Files)),
}
deleteToken := randomID(32)
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
if strings.TrimSpace(session.Options.PasswordHash) != "" {
box.PasswordSalt = session.Options.PasswordSalt
box.PasswordHash = session.Options.PasswordHash
} else if strings.TrimSpace(session.Options.Password) != "" {
salt, hash := hashPassword(session.Options.Password)
box.PasswordSalt = salt
box.PasswordHash = hash
}
for _, incoming := range session.Files {
fileID := randomID(8)
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name))
objectKey := boxObjectKey(box.ID, storedName)
contentType := incoming.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
box.Files = append(box.Files, File{
ID: fileID,
Name: cleanUploadDisplayName(incoming.Name),
StoredName: storedName,
Size: incoming.Size,
ContentType: contentType,
PreviewKind: previewKind(contentType),
ObjectKey: objectKey,
Processing: true,
UploadedAt: now,
})
}
if err := s.saveBoxRecord(box); err != nil {
return UploadResult{}, ResumableSession{}, err
}
session.Status = ResumableStatusProcessing
session.BoxID = box.ID
session.UpdatedAt = time.Now().UTC()
if err := s.saveResumableSession(session); err != nil {
return UploadResult{}, ResumableSession{}, err
}
return s.resultForBox(box, deleteToken), session, nil
}
func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context, sessionID string) (UploadResult, error) {
session, err := s.GetResumableSession(sessionID)
if err != nil {
return UploadResult{}, err
}
if session.Status == ResumableStatusCompleted && session.BoxID != "" {
box, err := s.GetBox(session.BoxID)
if err != nil {
return UploadResult{}, err
}
return s.resultForBox(box, ""), nil
}
if session.Status != ResumableStatusProcessing || session.BoxID == "" {
return UploadResult{}, fmt.Errorf("upload session is not processing")
}
box, err := s.GetBox(session.BoxID)
if err != nil {
return UploadResult{}, err
}
staged, err := s.resumableIncomingFiles(session)
if err != nil {
return UploadResult{}, err
}
if len(staged) != len(box.Files) {
return UploadResult{}, fmt.Errorf("processing file count mismatch")
}
backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil {
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err
}
for i, incoming := range staged {
source, err := incoming.Open()
if err != nil {
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err
}
file := box.Files[i]
if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil {
source.Close()
_ = backend.Delete(context.Background(), file.ObjectKey)
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err
}
source.Close()
box.Files[i].Processing = false
box.Files[i].ProcessingError = ""
box.Files[i].UploadedAt = time.Now().UTC()
if err := s.saveBoxRecord(box); err != nil {
return UploadResult{}, err
}
}
if err := s.writeBoxMetadata(box); err != nil {
s.logger.Warn("box metadata write failed after resumable processing", "source", "storage", "severity", "warn", "code", 4020, "box_id", box.ID, "error", err.Error())
}
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
return UploadResult{}, err
}
session.Status = ResumableStatusCompleted
session.UpdatedAt = time.Now().UTC()
if err := s.saveResumableSession(session); err != nil {
return UploadResult{}, err
}
return s.resultForBox(box, ""), nil
}
func (s *UploadService) markProcessingBoxFailed(box Box, cause error) error {
message := "upload processing failed"
if cause != nil && strings.TrimSpace(cause.Error()) != "" {
message = cause.Error()
}
s.logger.Warn("resumable upload box marked failed", "source", "user-upload", "severity", "warn", "code", 4021, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "files", len(box.Files), "error", message)
now := time.Now().UTC()
box.Trouble = true
box.TroubleReason = message
for i := range box.Files {
if box.Files[i].Processing || box.Files[i].ProcessingError == "" {
box.Files[i].Processing = false
box.Files[i].ProcessingError = message
if box.Files[i].UploadedAt.IsZero() {
box.Files[i].UploadedAt = now
}
}
}
if err := s.saveBoxRecord(box); err != nil {
s.logger.Warn("failed to save failed upload box state", "source", "user-upload", "severity", "warn", "code", 4022, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error())
return err
}
if err := s.writeBoxMetadata(box); err != nil {
s.logger.Warn("failed to write failed upload box metadata", "source", "user-upload", "severity", "warn", "code", 4023, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error())
return err
}
return nil
}
func (s *UploadService) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
session, err := s.GetResumableSession(sessionID)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
if err := resumableSessionWritable(session); err != nil {
return UploadResult{}, ResumableSession{}, err
}
completeFiles := make([]ResumableFile, 0, len(session.Files))
for _, file := range session.Files {
if resumableFileComplete(file) {
completeFiles = append(completeFiles, file)
}
}
if len(completeFiles) == 0 {
return UploadResult{}, ResumableSession{}, fmt.Errorf("no fully uploaded files to finish")
}
partial := session
partial.Files = completeFiles
staged, err := s.resumableIncomingFiles(partial)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
result, err := s.CreateBoxFromIncomingContext(ctx, staged, session.Options)
if err != nil {
return UploadResult{}, ResumableSession{}, err
}
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
return UploadResult{}, ResumableSession{}, err
}
session.Status = ResumableStatusCompleted
session.BoxID = result.BoxID
session.Files = completeFiles
session.UpdatedAt = time.Now().UTC()
if err := s.deleteResumableSession(session.ID); err != nil {
return UploadResult{}, ResumableSession{}, err
}
return result, session, nil
}
func (s *UploadService) CancelResumableSession(sessionID string) error {
session, err := s.GetResumableSession(sessionID)
if err != nil {
return err
}
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
return err
}
return s.deleteResumableSession(session.ID)
}
func (s *UploadService) CleanupExpiredResumableSessions(now time.Time) (int, error) {
candidates := make([]ResumableSession, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(resumableUploadsBucket)
if bucket == nil {
return nil
}
return bucket.ForEach(func(_, value []byte) error {
var session ResumableSession
if err := json.Unmarshal(value, &session); err != nil {
return err
}
if session.Status == ResumableStatusCompleted ||
session.Status == ResumableStatusCancelled ||
(session.Status == ResumableStatusUploading && !session.ExpiresAt.After(now)) {
candidates = append(candidates, session)
}
return nil
})
})
if err != nil {
return 0, err
}
for _, session := range candidates {
if err := os.RemoveAll(s.resumableSessionDirFor(session)); err != nil {
return 0, err
}
}
err = s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(resumableUploadsBucket)
if bucket == nil {
return nil
}
for _, session := range candidates {
if err := bucket.Delete([]byte(session.ID)); err != nil {
return err
}
}
return nil
})
return len(candidates), err
}
func (s *UploadService) deleteResumableSession(sessionID string) error {
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(resumableUploadsBucket)
if bucket == nil {
return nil
}
return bucket.Delete([]byte(sessionID))
})
}
func (s *UploadService) saveResumableSession(session ResumableSession) error {
if err := s.ensureResumableBucket(); err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
data, err := json.Marshal(session)
if err != nil {
return err
}
return tx.Bucket(resumableUploadsBucket).Put([]byte(session.ID), data)
})
}
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
sessionFiles := make([]ResumableFile, 0, len(files))
for _, file := range files {
file.Name = cleanUploadDisplayName(file.Name)
if file.Name == "." || file.Name == "" {
return nil, fmt.Errorf("file name is required")
}
if file.Size < 0 {
return nil, fmt.Errorf("file size is invalid")
}
fingerprint := strings.TrimSpace(file.Fingerprint)
key := resumableFileKey(file.Name, file.Size, fingerprint)
if existing != nil && existing[key] {
continue
}
if !opts.SkipSizeLimit {
if err := s.ValidateSize(file.Size); err != nil {
return nil, err
}
}
chunks := int((file.Size + chunkSize - 1) / chunkSize)
if chunks == 0 {
chunks = 1
}
sessionFiles = append(sessionFiles, ResumableFile{
ID: randomID(8),
Name: file.Name,
Size: file.Size,
ContentType: strings.TrimSpace(file.ContentType),
Fingerprint: fingerprint,
ChunkCount: chunks,
})
if existing != nil {
existing[key] = true
}
}
return sessionFiles, nil
}
func resumableFileKey(name string, size int64, fingerprint string) string {
return strings.TrimSpace(fingerprint) + "|" + cleanUploadDisplayName(name) + "|" + fmt.Sprintf("%d", size)
}
type resumableIncomingFile struct {
service *UploadService
session ResumableSession
file ResumableFile
}
func (f resumableIncomingFile) Name() string {
return f.file.Name
}
func (f resumableIncomingFile) Size() int64 {
return f.file.Size
}
func (f resumableIncomingFile) ContentType() string {
return f.file.ContentType
}
func (f resumableIncomingFile) Open() (io.ReadCloser, error) {
return &resumableChunkReader{
service: f.service,
session: f.session,
file: f.file,
}, nil
}
type resumableChunkReader struct {
service *UploadService
session ResumableSession
file ResumableFile
index int
current *os.File
}
func (r *resumableChunkReader) Read(p []byte) (int, error) {
for {
if r.current == nil {
if r.index >= r.file.ChunkCount {
return 0, io.EOF
}
chunk, err := os.Open(r.service.resumableChunkPathFor(r.session, r.file.ID, r.index))
if err != nil {
return 0, err
}
r.current = chunk
}
n, err := r.current.Read(p)
if err == io.EOF {
if closeErr := r.current.Close(); closeErr != nil {
r.current = nil
return n, closeErr
}
r.current = nil
r.index++
if n > 0 {
return n, nil
}
continue
}
return n, err
}
}
func (r *resumableChunkReader) Close() error {
if r.current == nil {
return nil
}
err := r.current.Close()
r.current = nil
return err
}
func (s *UploadService) resumableIncomingFiles(session ResumableSession) ([]IncomingFile, error) {
staged := make([]IncomingFile, 0, len(session.Files))
for _, file := range session.Files {
if len(file.UploadedChunks) != file.ChunkCount {
return nil, fmt.Errorf("file %s is missing chunks", file.Name)
}
var written int64
for i := 0; i < file.ChunkCount; i++ {
info, err := os.Stat(s.resumableChunkPathFor(session, file.ID, i))
if err != nil {
return nil, fmt.Errorf("file %s is missing chunks", file.Name)
}
written += info.Size()
}
if written != file.Size {
return nil, fmt.Errorf("chunk size total mismatch")
}
staged = append(staged, resumableIncomingFile{
service: s,
session: session,
file: file,
})
}
return staged, nil
}
func resumableSessionWritable(session ResumableSession) error {
if session.Status != ResumableStatusUploading {
return fmt.Errorf("upload session is not active")
}
if !session.ExpiresAt.After(time.Now().UTC()) {
return fmt.Errorf("upload session expired")
}
return nil
}
func resumableFileComplete(file ResumableFile) bool {
return file.ChunkCount > 0 && len(file.UploadedChunks) == file.ChunkCount
}
func expectedChunkSize(fileSize, chunkSize int64, index int) int64 {
offset := int64(index) * chunkSize
remaining := fileSize - offset
if remaining < 0 {
return 0
}
if remaining > chunkSize {
return chunkSize
}
return remaining
}
func addChunkIndex(chunks []int, index int) []int {
for _, chunk := range chunks {
if chunk == index {
return chunks
}
}
chunks = append(chunks, index)
sort.Ints(chunks)
return chunks
}
func resumableTokenHash(sessionID, token string) string {
sum := sha256.Sum256([]byte("warpbox-resumable:" + sessionID + ":" + token))
return hex.EncodeToString(sum[:])
}
func (s *UploadService) resumableSessionDir(sessionID string) string {
return filepath.Join(s.dataDir, "tmp", "uploads", sessionID)
}
func (s *UploadService) resumableSessionDirFor(session ResumableSession) string {
if strings.TrimSpace(session.ChunkRoot) != "" {
return filepath.Join(session.ChunkRoot, session.ID)
}
return s.resumableSessionDir(session.ID)
}
func (s *UploadService) resumableFileDir(sessionID, fileID string) string {
return filepath.Join(s.resumableSessionDir(sessionID), fileID)
}
func (s *UploadService) resumableFileDirFor(session ResumableSession, fileID string) string {
return filepath.Join(s.resumableSessionDirFor(session), fileID)
}
func (s *UploadService) resumableChunkPath(sessionID, fileID string, index int) string {
return filepath.Join(s.resumableFileDir(sessionID, fileID), fmt.Sprintf("%06d.part", index))
}
func (s *UploadService) resumableChunkPathFor(session ResumableSession, fileID string, index int) string {
return filepath.Join(s.resumableFileDirFor(session, fileID), fmt.Sprintf("%06d.part", index))
}

View File

@@ -37,6 +37,11 @@ type UploadPolicySettings struct {
ShortWindowSeconds int `json:"shortWindowSeconds"`
AnonymousStorageBackend string `json:"anonymousStorageBackend"`
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 {
@@ -89,6 +94,11 @@ func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*Settin
ShortWindowSeconds: defaults.ShortWindowSeconds,
AnonymousStorageBackend: defaults.AnonymousStorageBackend,
UserStorageBackend: defaults.UserStorageBackend,
ResumableUploadsEnabled: defaults.ResumableUploadsEnabled,
ResumableChunkSizeMB: defaults.ResumableChunkSizeMB,
ResumableRetentionHours: defaults.ResumableRetentionHours,
ResumableChunkMode: defaults.ResumableChunkMode,
ResumableChunkPath: defaults.ResumableChunkPath,
},
}
service.defaults = service.withBuiltinDefaultGaps(service.defaults)
@@ -143,6 +153,15 @@ func (s *SettingsService) withBuiltinDefaultGaps(settings UploadPolicySettings)
if strings.TrimSpace(settings.UserStorageBackend) == "" {
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
}
@@ -156,6 +175,13 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
if err := json.Unmarshal(data, &settings); err != nil {
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)
return nil
})
@@ -217,6 +243,15 @@ func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadP
if strings.TrimSpace(settings.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
}
@@ -422,6 +457,18 @@ func (s *SettingsService) validate(settings UploadPolicySettings) error {
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
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
}

View File

@@ -35,26 +35,35 @@ func (b *s3StorageBackend) ID() string { return b.cfg.ID }
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
cleanKey := cleanObjectKey(key)
opts := minio.PutObjectOptions{ContentType: contentType}
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
return err
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanKey, body, size, opts)
if err != nil {
return fmt.Errorf("s3 put object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
}
return nil
}
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
cleanKey := cleanObjectKey(key)
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanKey, minio.GetObjectOptions{})
if err != nil {
return StorageObject{}, err
return StorageObject{}, fmt.Errorf("s3 get object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
}
info, err := object.Stat()
if err != nil {
object.Close()
return StorageObject{}, err
return StorageObject{}, fmt.Errorf("s3 stat object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
}
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
}
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
cleanKey := cleanObjectKey(key)
if err := b.client.RemoveObject(ctx, b.cfg.Bucket, cleanKey, minio.RemoveObjectOptions{}); err != nil {
return fmt.Errorf("s3 delete object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
}
return nil
}
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
@@ -62,7 +71,7 @@ func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) erro
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
for object := range objects {
if object.Err != nil {
return object.Err
return fmt.Errorf("s3 list prefix %q in bucket %q failed: %w", prefix, b.cfg.Bucket, object.Err)
}
if err := b.Delete(ctx, object.Key); err != nil {
return err
@@ -75,7 +84,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
var total int64
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if object.Err != nil {
return 0, object.Err
return 0, fmt.Errorf("s3 usage list bucket %q failed: %w", b.cfg.Bucket, object.Err)
}
total += object.Size
}
@@ -85,7 +94,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
func (b *s3StorageBackend) Test(ctx context.Context) error {
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
if err != nil {
return err
return fmt.Errorf("s3 bucket check for %q failed: %w", b.cfg.Bucket, err)
}
if !exists {
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)

View File

@@ -16,6 +16,7 @@ import (
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
@@ -42,6 +43,8 @@ type UploadOptions struct {
ExpiresInMinutes int
MaxDownloads int
Password string
PasswordSalt string
PasswordHash string
ObfuscateMetadata bool
OwnerID string
CollectionID string
@@ -50,6 +53,56 @@ type UploadOptions struct {
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 {
ID string `json:"id"`
OwnerID string `json:"ownerId,omitempty"`
@@ -65,20 +118,59 @@ type Box struct {
Obfuscate bool `json:"obfuscate"`
CreatorIP string `json:"creatorIp,omitempty"`
StorageBackendID string `json:"storageBackendId,omitempty"`
Trouble bool `json:"trouble,omitempty"`
TroubleReason string `json:"troubleReason,omitempty"`
Files []File `json:"files"`
}
type File struct {
ID string `json:"id"`
Name string `json:"name"`
StoredName string `json:"storedName"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"`
ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
UploadedAt time.Time `json:"uploadedAt"`
ID string `json:"id"`
Name string `json:"name"`
StoredName string `json:"storedName"`
Size int64 `json:"size"`
ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"`
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
ArchiveListing string `json:"archiveListing,omitempty"`
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"`
}
func BoxHasTrouble(box Box) bool {
if box.Trouble || strings.TrimSpace(box.TroubleReason) != "" {
return true
}
for _, file := range box.Files {
if FileHasTrouble(file) {
return true
}
}
return false
}
func BoxTroubleReason(box Box) string {
if strings.TrimSpace(box.TroubleReason) != "" {
return box.TroubleReason
}
for _, file := range box.Files {
if strings.TrimSpace(file.ProcessingError) != "" {
return file.ProcessingError
}
}
if box.Trouble {
return "box has failed processing"
}
return ""
}
func FileHasTrouble(file File) bool {
return strings.TrimSpace(file.ProcessingError) != ""
}
type UploadResult struct {
@@ -98,6 +190,7 @@ type ResultFile struct {
Size string `json:"size"`
URL string `json:"url"`
ThumbnailURL string `json:"thumbnailUrl"`
Processing bool `json:"processing,omitempty"`
}
type AdminStats struct {
@@ -137,6 +230,9 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
if err := os.MkdirAll(dbDir, 0o755); err != nil {
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})
if err != nil {
@@ -195,6 +291,14 @@ func (s *UploadService) ValidateSize(size int64) 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 {
return UploadResult{}, fmt.Errorf("no files were uploaded")
}
@@ -229,13 +333,16 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
}
deleteToken := randomID(32)
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)
box.PasswordSalt = salt
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
}
@@ -258,6 +365,10 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
// selection into a single box). The box keeps its original expiry, password and
// other settings; only the new files are written.
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
return s.AppendIncomingFiles(boxID, multipartIncomingFiles(files), opts)
}
func (s *UploadService) AppendIncomingFiles(boxID string, files []IncomingFile, opts UploadOptions) (UploadResult, error) {
if len(files) == 0 {
return UploadResult{}, fmt.Errorf("no files were uploaded")
}
@@ -265,7 +376,7 @@ func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader,
if err != nil {
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
}
if err := s.SaveBox(box); err != nil {
@@ -286,14 +397,26 @@ func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader,
// appends the file metadata to box.Files. The box's StorageBackendID determines
// where files land, so it works for both new and existing boxes.
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
return s.writeIncomingFilesToBox(context.Background(), box, multipartIncomingFiles(files), opts)
}
func multipartIncomingFiles(files []*multipart.FileHeader) []IncomingFile {
incoming := make([]IncomingFile, 0, len(files))
for _, file := range files {
incoming = append(incoming, multipartIncomingFile{header: file})
}
return incoming
}
func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, files []IncomingFile, opts UploadOptions) error {
backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil {
return err
}
for _, header := range files {
for _, incoming := range files {
if !opts.SkipSizeLimit {
if err := s.ValidateSize(header.Size); err != nil {
if err := s.ValidateSize(incoming.Size()); err != nil {
return err
}
}
@@ -303,16 +426,16 @@ func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader,
maxSize = 0
}
file, err := header.Open()
file, err := incoming.Open()
if err != nil {
return err
}
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)
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType := incoming.ContentType()
if contentType == "" || contentType == "application/octet-stream" {
buffer := make([]byte, 512)
n, _ := file.Read(buffer)
contentType = http.DetectContentType(buffer[:n])
@@ -321,17 +444,18 @@ func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader,
}
}
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
if err := s.writeUploadedObject(ctx, backend, objectKey, file, incoming.Size(), maxSize, contentType); err != nil {
file.Close()
_ = backend.Delete(context.Background(), objectKey)
return err
}
file.Close()
box.Files = append(box.Files, File{
ID: fileID,
Name: filepath.Base(header.Filename),
Name: cleanUploadDisplayName(incoming.Name()),
StoredName: storedName,
Size: header.Size,
Size: incoming.Size(),
ContentType: contentType,
PreviewKind: previewKind(contentType),
ObjectKey: objectKey,
@@ -341,6 +465,36 @@ func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader,
return nil
}
func cleanUploadDisplayName(name string) string {
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
clean = strings.TrimLeft(clean, "/")
clean = path.Clean(clean)
if clean == "." || clean == "/" || clean == "" {
return "download"
}
parts := strings.Split(clean, "/")
safeParts := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." || part == ".." {
continue
}
part = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f || r == '/' || r == '\\' {
return -1
}
return r
}, part)
if part != "" {
safeParts = append(safeParts, part)
}
}
if len(safeParts) == 0 {
return "download"
}
return strings.Join(safeParts, "/")
}
func (s *UploadService) GetBox(id string) (Box, error) {
var box Box
err := s.db.View(func(tx *bbolt.Tx) error {
@@ -645,6 +799,12 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
if key := s.ThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
if key := s.ArchiveListingObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
}
box.Files = append(box.Files[:index], box.Files[index+1:]...)
@@ -732,7 +892,30 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
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) {
if file.Processing {
return StorageObject{}, fmt.Errorf("file is still processing")
}
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return StorageObject{}, err
@@ -752,6 +935,30 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F
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) {
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
@@ -854,6 +1061,13 @@ func (s *UploadService) WriteZip(w io.Writer, 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 == "" {
box.StorageBackendID = StorageBackendLocal
}
@@ -863,10 +1077,7 @@ func (s *UploadService) SaveBox(box Box) error {
}
return s.db.Update(func(tx *bbolt.Tx) error {
if err := tx.Bucket(boxesBucket).Put([]byte(box.ID), data); err != nil {
return err
}
return s.writeBoxMetadata(box)
return tx.Bucket(boxesBucket).Put([]byte(box.ID), data)
})
}
@@ -879,6 +1090,7 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
Size: helpers.FormatBytes(file.Size),
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
Processing: file.Processing,
})
}
@@ -928,21 +1140,34 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
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
putSize := size
if maxSize > 0 {
reader = io.LimitReader(source, maxSize+1)
var buffer bytes.Buffer
written, err := io.Copy(&buffer, reader)
if err != nil {
return err
}
if written > maxSize {
if size > maxSize {
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 {
@@ -957,6 +1182,10 @@ func randomID(byteCount int) string {
return base64.RawURLEncoding.EncodeToString(data)
}
func RandomPublicToken(byteCount int) string {
return randomID(byteCount)
}
func hashPassword(password string) (string, string) {
salt := randomID(18)
return salt, passwordHash(salt, password)

View File

@@ -126,6 +126,303 @@ func TestLocalStorageBackendAndLegacyFallback(t *testing.T) {
object.Body.Close()
}
func TestResumableSessionUploadOutOfOrderAndComplete(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{{
Name: "note.txt",
Size: 11,
ContentType: "text/plain",
Fingerprint: "sha256:first-chunk",
}}, UploadOptions{MaxDays: 1, Password: "secret"}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if session.ResumeToken == "" || session.ResumeTokenHash == "" {
t.Fatalf("resumable session did not create resume token: %+v", session)
}
if !service.VerifyResumableToken(session, session.ResumeToken) {
t.Fatalf("VerifyResumableToken rejected correct token")
}
if service.VerifyResumableToken(session, "wrong-token") {
t.Fatalf("VerifyResumableToken accepted wrong token")
}
stored, err := service.GetResumableSession(session.ID)
if err != nil {
t.Fatalf("GetResumableSession returned error: %v", err)
}
if stored.ResumeToken != "" {
t.Fatalf("stored session leaked raw resume token")
}
if strings.Contains(stored.ResumeTokenHash, session.ResumeToken) {
t.Fatalf("stored token hash contains raw token")
}
if !service.VerifyResumableToken(stored, session.ResumeToken) {
t.Fatalf("stored session rejected correct token")
}
if session.Options.Password != "" || session.Options.PasswordHash == "" || session.Options.PasswordSalt == "" {
t.Fatalf("resumable session did not hash password before storage: %+v", session.Options)
}
if session.Files[0].ChunkCount != 3 {
t.Fatalf("ChunkCount = %d, want 3", session.Files[0].ChunkCount)
}
if session.Files[0].Fingerprint != "sha256:first-chunk" {
t.Fatalf("Fingerprint = %q", session.Files[0].Fingerprint)
}
for index, body := range map[int]string{2: "rld", 0: "hell", 1: "o wo"} {
updated, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, index, strings.NewReader(body))
if err != nil {
t.Fatalf("PutResumableChunk(%d) returned error: %v", index, err)
}
if len(updated.Files[0].UploadedChunks) == 0 {
t.Fatalf("UploadedChunks was not updated")
}
}
result, completed, err := service.CompleteResumableSession(testContext(), session.ID)
if err != nil {
t.Fatalf("CompleteResumableSession returned error: %v", err)
}
if completed.Status != ResumableStatusCompleted || completed.BoxID != result.BoxID {
t.Fatalf("completed session = %+v, result = %+v", completed, result)
}
box := getTestBox(t, service, result.BoxID)
if box.PasswordHash == "" || box.PasswordSalt == "" || box.PasswordHash != session.Options.PasswordHash {
t.Fatalf("completed box did not preserve hashed password")
}
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
if err != nil {
t.Fatalf("OpenFileObject returned error: %v", err)
}
data, err := io.ReadAll(object.Body)
object.Body.Close()
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if string(data) != "hello world" {
t.Fatalf("object body = %q", string(data))
}
if _, err := os.Stat(service.resumableSessionDir(session.ID)); !os.IsNotExist(err) {
t.Fatalf("resumable temp dir after complete error = %v, want os.ErrNotExist", err)
}
replayed, replayedSession, err := service.CompleteResumableSession(testContext(), session.ID)
if err != nil {
t.Fatalf("CompleteResumableSession replay returned error: %v", err)
}
if replayed.BoxID != result.BoxID || replayedSession.Status != ResumableStatusCompleted {
t.Fatalf("replayed result = %+v, session = %+v, want box %s completed", replayed, replayedSession, result.BoxID)
}
}
func TestResumableCompleteRejectsMissingChunks(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{{
Name: "note.txt",
Size: 8,
ContentType: "text/plain",
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("hell")); err != nil {
t.Fatalf("PutResumableChunk returned error: %v", err)
}
if _, _, err := service.CompleteResumableSession(testContext(), session.ID); err == nil {
t.Fatalf("CompleteResumableSession accepted missing chunks")
}
}
func TestProcessingResumableFailureMarksBoxFailed(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{{
Name: "note.txt",
Size: 4,
ContentType: "text/plain",
}}, UploadOptions{MaxDays: 1, StorageBackendID: "missing"}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("note")); err != nil {
t.Fatalf("PutResumableChunk returned error: %v", err)
}
result, processing, err := service.CreateProcessingBoxFromResumable(session.ID)
if err != nil {
t.Fatalf("CreateProcessingBoxFromResumable returned error: %v", err)
}
if processing.Status != ResumableStatusProcessing {
t.Fatalf("session status = %q, want processing", processing.Status)
}
if _, err := service.FinalizeProcessingResumableSession(testContext(), session.ID); err == nil {
t.Fatalf("FinalizeProcessingResumableSession accepted missing backend")
}
box := getTestBox(t, service, result.BoxID)
if len(box.Files) != 1 {
t.Fatalf("box files = %+v", box.Files)
}
if box.Files[0].Processing {
t.Fatalf("failed file is still marked processing: %+v", box.Files[0])
}
if box.Files[0].ProcessingError == "" {
t.Fatalf("failed file did not store processing error: %+v", box.Files[0])
}
if !box.Trouble {
t.Fatalf("failed box was not marked as trouble: %+v", box)
}
if box.TroubleReason == "" {
t.Fatalf("failed box did not store trouble reason: %+v", box)
}
}
func TestResumablePartialCompleteKeepsOnlyFinishedFiles(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{
{Name: "done.txt", Size: 4, ContentType: "text/plain", Fingerprint: "done"},
{Name: "partial.txt", Size: 8, ContentType: "text/plain", Fingerprint: "partial"},
}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("done")); err != nil {
t.Fatalf("PutResumableChunk done returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[1].ID, 0, strings.NewReader("part")); err != nil {
t.Fatalf("PutResumableChunk partial returned error: %v", err)
}
result, completed, err := service.CompleteUploadedResumableSession(testContext(), session.ID)
if err != nil {
t.Fatalf("CompleteUploadedResumableSession returned error: %v", err)
}
if completed.Status != ResumableStatusCompleted || completed.BoxID != result.BoxID || len(completed.Files) != 1 {
t.Fatalf("completed session = %+v, result = %+v", completed, result)
}
box := getTestBox(t, service, result.BoxID)
if len(box.Files) != 1 || box.Files[0].Name != "done.txt" {
t.Fatalf("partial completion box files = %+v", box.Files)
}
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
if err != nil {
t.Fatalf("OpenFileObject returned error: %v", err)
}
data, err := io.ReadAll(object.Body)
object.Body.Close()
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if string(data) != "done" {
t.Fatalf("partial completion object = %q", string(data))
}
if _, err := service.GetResumableSession(session.ID); !os.IsNotExist(err) {
t.Fatalf("GetResumableSession after partial complete error = %v, want os.ErrNotExist", err)
}
if _, err := os.Stat(service.resumableSessionDir(session.ID)); !os.IsNotExist(err) {
t.Fatalf("resumable temp dir after partial complete error = %v, want os.ErrNotExist", err)
}
}
func TestResumablePartialCompleteRejectsNoFinishedFiles(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{{
Name: "partial.txt",
Size: 8,
ContentType: "text/plain",
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("part")); err != nil {
t.Fatalf("PutResumableChunk returned error: %v", err)
}
if _, _, err := service.CompleteUploadedResumableSession(testContext(), session.ID); err == nil {
t.Fatalf("CompleteUploadedResumableSession accepted no completed files")
}
if _, err := service.GetResumableSession(session.ID); err != nil {
t.Fatalf("GetResumableSession after failed partial complete returned error: %v", err)
}
}
func TestResumableSessionCanAddFilesBeforeComplete(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{{
Name: "one.txt",
Size: 4,
ContentType: "text/plain",
Fingerprint: "one",
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("one!")); err != nil {
t.Fatalf("PutResumableChunk one returned error: %v", err)
}
updated, err := service.AddResumableFiles(session.ID, []ResumableFileInput{{
Name: "two.txt",
Size: 4,
ContentType: "text/plain",
Fingerprint: "two",
}})
if err != nil {
t.Fatalf("AddResumableFiles returned error: %v", err)
}
if len(updated.Files) != 2 {
t.Fatalf("files after add = %d, want 2", len(updated.Files))
}
if updated.Files[0].UploadedChunks[0] != 0 {
t.Fatalf("existing uploaded chunk was not preserved: %+v", updated.Files[0])
}
if _, err := service.AddResumableFiles(session.ID, []ResumableFileInput{{
Name: "two.txt",
Size: 4,
ContentType: "text/plain",
Fingerprint: "two",
}}); err != nil {
t.Fatalf("duplicate AddResumableFiles returned error: %v", err)
}
updated, err = service.GetResumableSession(session.ID)
if err != nil {
t.Fatalf("GetResumableSession returned error: %v", err)
}
if len(updated.Files) != 2 {
t.Fatalf("duplicate add changed file count to %d", len(updated.Files))
}
if _, err := service.PutResumableChunk(testContext(), session.ID, updated.Files[1].ID, 0, strings.NewReader("two!")); err != nil {
t.Fatalf("PutResumableChunk two returned error: %v", err)
}
result, _, err := service.CompleteResumableSession(testContext(), session.ID)
if err != nil {
t.Fatalf("CompleteResumableSession returned error: %v", err)
}
box := getTestBox(t, service, result.BoxID)
if len(box.Files) != 2 {
t.Fatalf("completed box file count = %d, want 2", len(box.Files))
}
}
func TestResumableCleanupRemovesExpiredSessionsAndChunks(t *testing.T) {
service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{{
Name: "note.txt",
Size: 4,
ContentType: "text/plain",
}}, UploadOptions{MaxDays: 1}, 4, time.Hour, "")
if err != nil {
t.Fatalf("CreateResumableSession returned error: %v", err)
}
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("hell")); err != nil {
t.Fatalf("PutResumableChunk returned error: %v", err)
}
cleaned, err := service.CleanupExpiredResumableSessions(session.ExpiresAt.Add(time.Second))
if err != nil {
t.Fatalf("CleanupExpiredResumableSessions returned error: %v", err)
}
if cleaned != 1 {
t.Fatalf("cleaned = %d, want 1", cleaned)
}
if _, err := service.GetResumableSession(session.ID); !os.IsNotExist(err) {
t.Fatalf("GetResumableSession after cleanup error = %v, want os.ErrNotExist", err)
}
if _, err := os.Stat(service.resumableSessionDir(session.ID)); !os.IsNotExist(err) {
t.Fatalf("resumable temp dir after cleanup error = %v, want os.ErrNotExist", err)
}
}
func TestContaboStorageConfigAllowsDisplayNamesWithSpaces(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{

View File

@@ -7,6 +7,9 @@ import (
"time"
)
// RobotsNone is used for private, protected, expired, or temporary pages.
const RobotsNone = "noindex,nofollow,noarchive"
type Renderer struct {
templates map[string]*template.Template
appName string
@@ -15,16 +18,23 @@ type Renderer struct {
}
type PageData struct {
AppName string
AppVersion string
BaseURL string
Title string
Description string
ImageURL string
CurrentYear int
CurrentUser any
CSRFToken string
Data any
AppName string
AppVersion string
BaseURL string
CanonicalURL string
Robots string
OGType string
Title string
Description string
ImageURL string
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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -0,0 +1,81 @@
#requires -version 5
<#
.SYNOPSIS
warpbox: command line uploader for Warpbox
.DESCRIPTION
Set the server once, then upload anything:
setx WARPBOX_HOST "https://your.warpbox.host"
warpbox .\report.pdf
Install (PowerShell):
iwr "$env:WARPBOX_HOST/static/api/warpbox.ps1" -OutFile $HOME\warpbox.ps1
# add a function to your $PROFILE: function warpbox { & "$HOME\warpbox.ps1" @args }
Auth: set the token once so it never lands in your command history.
setx WARPBOX_TOKEN "wbx_your_token"
Create a token under Account, Access tokens.
.EXAMPLE
.\warpbox.ps1 .\report.pdf
.EXAMPLE
.\warpbox.ps1 -Password 123 -Expiry 2d .\photo.png .\clip.mp4
#>
[CmdletBinding()]
param(
[Alias('p')][string]$Password,
[Alias('e')][string]$Expiry,
[Alias('n')][int]$MaxDownloads,
[Alias('o')][switch]$Obfuscate,
[string]$Server = $env:WARPBOX_HOST,
[string]$Auth = $env:WARPBOX_TOKEN,
[string]$AuthFile,
[switch]$Json,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Files
)
if ($Help -or -not $Files) {
Write-Host 'warpbox: upload files to Warpbox'
Write-Host 'USAGE: warpbox.ps1 [-Password pw] [-Expiry 2d] [-MaxDownloads n] [-Obfuscate] [-Json] <file> [file ...]'
Write-Host 'SERVER: set WARPBOX_HOST in your environment (setx WARPBOX_HOST "https://your.host")'
Write-Host 'AUTH: set WARPBOX_TOKEN in your environment (setx WARPBOX_TOKEN "wbx_...")'
if (-not $Files -and -not $Help) { exit 2 } else { exit 0 }
}
if (-not $Server) {
Write-Error 'warpbox: no server set. Use -Server <url> or set WARPBOX_HOST'
exit 2
}
if ($AuthFile) { $Auth = (Get-Content -Raw $AuthFile).Trim() }
function ConvertTo-Minutes($v) {
if ($v -match '^(\d+)([mhdw]?)$') {
$n = [int]$Matches[1]
switch ($Matches[2]) {
'h' { return $n * 60 }
'd' { return $n * 1440 }
'w' { return $n * 10080 }
default { return $n }
}
}
return $v
}
# Expand wildcards (PowerShell does not expand them in arguments).
$expanded = @()
foreach ($f in $Files) {
$hits = Get-ChildItem -Path $f -File -ErrorAction SilentlyContinue
if ($hits) { $expanded += $hits.FullName } else { $expanded += $f }
}
$curlArgs = @('-fS')
foreach ($f in $expanded) { $curlArgs += @('-F', "file=@$f") }
if ($Password) { $curlArgs += @('-F', "password=$Password") }
if ($Expiry) { $curlArgs += @('-F', "expires_minutes=$(ConvertTo-Minutes $Expiry)") }
if ($MaxDownloads) { $curlArgs += @('-F', "max_downloads=$MaxDownloads") }
if ($Obfuscate) { $curlArgs += @('-F', 'obfuscate_metadata=on') }
if ($Auth) { $curlArgs += @('-H', "Authorization: Bearer $Auth") }
if ($Json) { $curlArgs += @('-H', 'Accept: application/json') }
$curlArgs += "$($Server.TrimEnd('/'))/api/v1/upload"
& curl.exe @curlArgs

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# warpbox: command line uploader for Warpbox
#
# Set the server once, then upload anything:
# export WARPBOX_HOST=https://your.warpbox.host
# warpbox ./report.pdf
#
# Install:
# curl -fsSL "$WARPBOX_HOST/static/api/warpbox.sh" -o ~/.local/bin/warpbox
# chmod +x ~/.local/bin/warpbox
# # make sure ~/.local/bin is on your PATH
#
set -eo pipefail
WARPBOX_HOST="${WARPBOX_HOST:-}"
AUTH="${WARPBOX_TOKEN:-}"
PASSWORD=""
EXPIRY=""
MAX_DOWNLOADS=""
OBFUSCATE=""
AS_JSON=0
FILES=()
usage() {
cat <<'EOF'
warpbox: upload files to Warpbox from the terminal
USAGE:
warpbox [options] <file> [file ...]
OPTIONS:
-p, --password <pw> Require a password to view/download the box
-e, --expiry <dur> Lifetime before expiry: 30m, 6h, 2d, 1w (or bare minutes)
-n, --max-downloads <n> Expire after N downloads
-o, --obfuscate Hide file names/counts until unlocked (needs --password)
--host <url> Warpbox server to upload to (or set WARPBOX_HOST)
--auth <token> API token (prefer the WARPBOX_TOKEN env var, see AUTH)
--auth-file <path> Read the API token from a file (safer than --auth)
--json Print the full JSON response instead of just the URL
-h, --help Show this help
AUTH:
Uploads are anonymous unless a token is supplied. The most secure option is the
WARPBOX_TOKEN environment variable, so the token never lands in your shell
history or the process list:
export WARPBOX_TOKEN=wbx_your_token
warpbox ./photo.png
Create a token under Account, Access tokens. Avoid --auth on shared machines.
EXAMPLES:
warpbox ./report.pdf
warpbox --password 123 --expiry 2d ./first_file.zip ./whatever.png ./all_*_photos.jpg
warpbox --max-downloads 5 --json ./build.zip
EOF
}
expiry_to_minutes() {
local v="$1" num unit
num="${v%%[mhdw]*}"
unit="${v##*[0-9]}"
case "$unit" in
h) echo $(( num * 60 )) ;;
d) echo $(( num * 1440 )) ;;
w) echo $(( num * 10080 )) ;;
m|"") echo "$num" ;;
*) echo "$num" ;;
esac
}
while [ $# -gt 0 ]; do
case "$1" in
-p|--password) PASSWORD="$2"; shift 2 ;;
-e|--expiry) EXPIRY="$2"; shift 2 ;;
-n|--max-downloads) MAX_DOWNLOADS="$2"; shift 2 ;;
-o|--obfuscate) OBFUSCATE="on"; shift ;;
--host) WARPBOX_HOST="$2"; shift 2 ;;
--auth) AUTH="$2"; shift 2 ;;
--auth-file) AUTH="$(cat "$2")"; shift 2 ;;
--json) AS_JSON=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; while [ $# -gt 0 ]; do FILES+=("$1"); shift; done ;;
-*) echo "warpbox: unknown option $1" >&2; exit 2 ;;
*) FILES+=("$1"); shift ;;
esac
done
if [ -z "$WARPBOX_HOST" ]; then
echo "warpbox: no server set. Use --host <url> or export WARPBOX_HOST=<url>" >&2
exit 2
fi
if [ ${#FILES[@]} -eq 0 ]; then
echo "warpbox: no files given" >&2
echo >&2
usage >&2
exit 2
fi
CURL_ARGS=()
for f in "${FILES[@]}"; do
if [ ! -f "$f" ]; then
echo "warpbox: not a file: $f" >&2
exit 2
fi
CURL_ARGS+=(-F "file=@${f}")
done
[ -n "$PASSWORD" ] && CURL_ARGS+=(-F "password=${PASSWORD}")
[ -n "$EXPIRY" ] && CURL_ARGS+=(-F "expires_minutes=$(expiry_to_minutes "$EXPIRY")")
[ -n "$MAX_DOWNLOADS" ] && CURL_ARGS+=(-F "max_downloads=${MAX_DOWNLOADS}")
[ -n "$OBFUSCATE" ] && CURL_ARGS+=(-F "obfuscate_metadata=on")
HEADERS=()
[ -n "$AUTH" ] && HEADERS+=(-H "Authorization: Bearer ${AUTH}")
[ "$AS_JSON" -eq 1 ] && HEADERS+=(-H "Accept: application/json")
exec curl -fS "${HEADERS[@]}" "${CURL_ARGS[@]}" "${WARPBOX_HOST%/}/api/v1/upload"

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,263 @@
.warpbox-dialog-overlay {
position: fixed;
inset: 0;
z-index: 130;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 60%, transparent);
backdrop-filter: blur(8px);
opacity: 0;
transition: opacity 160ms ease;
}
.warpbox-dialog-overlay.is-visible {
opacity: 1;
}
.warpbox-dialog {
position: relative;
width: min(28rem, 100%);
max-height: min(34rem, 90vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--card-foreground);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(0.6rem) scale(0.98);
transition: opacity 160ms ease, transform 160ms ease;
}
.warpbox-dialog:focus {
outline: none;
}
.warpbox-dialog-overlay.is-visible .warpbox-dialog {
opacity: 1;
transform: translateY(0) scale(1);
}
.warpbox-dialog-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.85rem;
align-items: center;
padding: 1.1rem 3.25rem 0 1.1rem;
}
.warpbox-dialog-icon {
width: 1.9rem;
height: 1.9rem;
display: grid;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 800;
line-height: 1;
}
.warpbox-dialog-warning .warpbox-dialog-icon {
background: color-mix(in srgb, var(--primary) 26%, transparent);
color: var(--primary-hover);
}
.warpbox-dialog-error .warpbox-dialog-icon {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.warpbox-dialog-title {
margin: 0;
font-size: 1.1rem;
line-height: 1.3;
}
.warpbox-dialog-close {
position: absolute;
top: 1.1rem;
right: 1.1rem;
z-index: 2;
min-height: 1.9rem;
height: 1.9rem;
width: 1.9rem;
padding: 0;
border-color: var(--border);
color: var(--muted-foreground);
background: var(--surface-1);
font-size: 1rem;
line-height: 1;
}
.warpbox-dialog-close:hover {
color: var(--foreground);
background: var(--surface-1-hover);
}
.warpbox-dialog-body {
padding: 0.85rem 1.1rem 1.1rem;
overflow: auto;
}
.warpbox-dialog-message {
margin: 0 0 0.75rem;
color: var(--muted-foreground);
font-size: 0.92rem;
line-height: 1.5;
overflow-wrap: anywhere;
}
.warpbox-dialog-message:last-child {
margin-bottom: 0;
}
.warpbox-dialog-field {
width: 100%;
border: 1px solid var(--input);
border-radius: calc(var(--radius) - 0.35rem);
background: var(--surface-1);
color: var(--foreground);
padding: 0.55rem 0.7rem;
font: inherit;
}
.warpbox-dialog-field:focus {
outline: 2px solid var(--ring);
outline-offset: 1px;
}
.warpbox-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding: 0 1.1rem 1.1rem;
}
html.warpbox-dialog-open,
html.warpbox-dialog-open body {
overflow: hidden;
touch-action: none;
}
.dialog-file-list {
display: grid;
gap: 0.5rem;
margin-top: 0.25rem;
max-height: 14rem;
overflow: auto;
padding-right: 0.25rem;
}
.dialog-file-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.35rem);
background: var(--surface-1);
}
.dialog-file-icon {
width: 1.35rem;
height: 1.35rem;
color: var(--muted-foreground);
}
.dialog-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.86rem;
}
.dialog-file-size {
color: var(--muted-foreground);
font-size: 0.8rem;
white-space: nowrap;
}
:root[data-theme="retro"] .warpbox-dialog {
border: 1px solid #000000;
border-radius: 0;
background: #c0c0c0;
color: #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 4px 4px 0 rgba(0, 0, 0, 0.45);
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
}
:root[data-theme="retro"] .warpbox-dialog-head {
padding-top: 0.2rem;
}
:root[data-theme="retro"] .warpbox-dialog::before {
content: "Warpbox";
display: block;
margin: 0.18rem 0.18rem 0;
padding: 0.22rem 0.35rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 0.78rem;
font-weight: 700;
}
:root[data-theme="retro"] .warpbox-dialog-error::before {
content: "Warpbox - Error";
}
:root[data-theme="retro"] .warpbox-dialog-warning::before {
content: "Warpbox - Warning";
}
:root[data-theme="retro"] .warpbox-dialog-info::before {
content: "Warpbox - Info";
}
:root[data-theme="retro"] .warpbox-dialog-icon {
border: 1px solid #000000;
background: #ffffff;
color: #000078;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .warpbox-dialog-warning .warpbox-dialog-icon {
color: #9a5b00;
}
:root[data-theme="retro"] .warpbox-dialog-error .warpbox-dialog-icon {
color: #c00000;
}
:root[data-theme="retro"] .warpbox-dialog-message {
color: #000000;
}
:root[data-theme="retro"] .warpbox-dialog-close {
top: 0.36rem;
right: 0.3rem;
width: 1.1rem;
height: 0.95rem;
min-height: 0.95rem;
background: #c0c0c0;
color: #000000;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
font-size: 0.6rem;
font-weight: 700;
}
@media (max-width: 640px) {
.warpbox-dialog-overlay {
padding: 0.75rem;
}
.warpbox-dialog {
width: 100%;
}
}

View File

@@ -152,16 +152,16 @@
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
are styled as their own Win98 controls below, so they're excluded here. */
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) {
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill) {
color: #0000ee;
text-decoration: underline;
}
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited {
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):visited {
color: #551a8b;
}
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover {
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):hover {
color: #ee0000;
}
@@ -592,31 +592,432 @@
content: "\23F1 ";
}
/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat
buttons that raise on hover and depress when active. */
/* The file browser becomes a Win98 Explorer window: blue titlebar, grey
toolbar, sunken content pane and flat rows. */
:root[data-theme="retro"] .file-browser-window {
border: 1px solid #000000;
border-radius: 0;
background: #c0c0c0;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .file-browser-titlebar {
min-height: 1.8rem;
margin: 3px 3px 0;
padding: 0.2rem 0.45rem;
border: 0;
background: linear-gradient(to right, #000078 0%, #000078 80%, #0f80cd 100%);
color: #ffffff;
}
:root[data-theme="retro"] .file-browser-titlebar strong,
:root[data-theme="retro"] .file-browser-titlebar span {
color: #ffffff;
font-size: 0.78rem;
}
:root[data-theme="retro"] .file-browser-window-actions {
display: none;
}
:root[data-theme="retro"] .file-browser-toolbar {
justify-content: space-between;
margin: 0 3px;
padding: 3px;
border: 0;
border-bottom: 1px solid #808080;
background: #c0c0c0;
}
:root[data-theme="retro"] .view-toolbar {
justify-content: flex-start;
gap: 2px;
margin-top: 1rem;
padding: 3px;
margin-top: 0;
padding: 0;
background: #c0c0c0;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
border: 0;
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;
border: 1px solid transparent;
box-shadow: none;
font-weight: 400;
}
:root[data-theme="retro"] .view-toolbar .button:hover {
:root[data-theme="retro"] .view-toolbar .icon-button {
width: 2.2rem;
height: 2rem;
padding: 0;
}
:root[data-theme="retro"] .view-toolbar .icon-button .svg-icon {
margin: 0;
display: block;
}
:root[data-theme="retro"] .view-toolbar .button:hover,
:root[data-theme="retro"] .file-browser-toolbar > .button:hover {
background: #c0c0c0;
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;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .file-browser-head {
margin: 0 3px;
border: 0;
border-bottom: 1px solid #808080;
background: #c0c0c0;
color: #000000;
text-transform: none;
}
:root[data-theme="retro"] .file-browser {
margin: 0 3px 3px;
border: 1px solid #000000;
background: #ffffff;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .download-item {
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
:root[data-theme="retro"] .file-open {
border-radius: 0;
color: #000000;
}
:root[data-theme="retro"] .file-open:hover,
:root[data-theme="retro"] .file-open:focus-visible {
background: transparent;
color: #000000;
outline: 2px solid #000078;
outline-offset: -2px;
}
:root[data-theme="retro"] .file-browser.is-list .file-card:hover,
:root[data-theme="retro"] .file-browser.is-list .file-card:focus-within {
background: transparent;
outline: 2px solid #000078;
outline-offset: -2px;
}
:root[data-theme="retro"] .file-browser.is-list .file-card:hover .file-open,
:root[data-theme="retro"] .file-browser.is-list .file-card:focus-within .file-open {
outline: 0;
}
:root[data-theme="retro"] .file-media {
border: 0;
border-radius: 0;
background: transparent;
}
:root[data-theme="retro"] .file-browser.is-thumbs .file-open {
align-content: start;
justify-content: center;
}
:root[data-theme="retro"] .file-browser.is-thumbs .file-media {
justify-self: center;
align-self: start;
}
:root[data-theme="retro"] .file-type,
:root[data-theme="retro"] .file-size,
:root[data-theme="retro"] .file-main small {
color: inherit;
}
/* ------------------------------------------------------------------------- */
/* API documentation: sidebar + panels as Win98 windows */
/* The new .api-docs layout uses dark revamp tokens by default, which are */
/* unreadable on the black retro desktop. Re-skin it as Win98 chrome: a */
/* raised silver sidebar window, plain light section intros on the desktop, */
/* and each card a silver window with a navy title bar from its heading. */
/* ------------------------------------------------------------------------- */
/* Sidebar = raised silver window with a real title bar from its <h1>. */
:root[data-theme="retro"] .api-sidebar {
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
background-color: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
padding: 0.5rem;
gap: 0.5rem;
}
:root[data-theme="retro"] .api-sidebar > .kicker {
display: none;
}
:root[data-theme="retro"] .api-sidebar-title {
margin: -0.5rem -0.5rem 0.5rem;
font-size: 0.9rem;
}
:root[data-theme="retro"] .api-nav {
border-left: 0;
padding-left: 0;
gap: 0.2rem;
}
/* Nav entries are flat silver list items; the active one is a navy bar. */
:root[data-theme="retro"] .api-nav-link {
color: #000000;
font-weight: 700;
text-decoration: none;
border: 1px solid transparent;
}
:root[data-theme="retro"] .api-nav-link:hover {
background: #d4d0c8;
color: #000000;
}
:root[data-theme="retro"] .api-nav-link.is-active {
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
border-color: #000000;
}
:root[data-theme="retro"] .api-sidebar-meta {
border-top: 1px solid #808080;
box-shadow: 0 -1px 0 #ffffff;
padding-top: 0.5rem;
margin-top: 0.5rem;
}
/* Section intro becomes a real Win98 window: silver body, the <h2> a navy
title bar with a fake close button, and the subtitle as black body text.
This fixes the default black-on-black inline code in headings/intros. */
:root[data-theme="retro"] .panel-head {
max-width: none;
margin-bottom: 1.5rem;
padding: 1rem;
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
background-color: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
}
/* The kicker is redundant once the title sits in a title bar; hide it so the
bar can hug the top edge (the markup puts the kicker before the h2). */
:root[data-theme="retro"] .panel-head .kicker {
display: none;
}
:root[data-theme="retro"] .panel-head h2 {
position: relative;
margin: -1rem -1rem 1rem;
padding: 0.35rem 1.8rem 0.35rem 0.5rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 1rem;
font-weight: 700;
}
/* Inline code in the title (e.g. "The warpbox CLI") reads white on the bar
instead of the default black. */
:root[data-theme="retro"] .panel-head h2 code {
color: #ffffff;
background: transparent;
padding: 0;
}
:root[data-theme="retro"] .panel-head h2::after {
content: "\2715";
position: absolute;
top: 50%;
right: 0.4rem;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 1.15rem;
height: 1rem;
background: #c0c0c0;
color: #000000;
font-size: 0.7rem;
font-weight: 700;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .panel-head .lead {
color: #1a1a1a;
margin: 0;
}
/* Inline code in the subtitle: sunken white field, black text. */
:root[data-theme="retro"] .panel-head .lead code {
color: #000000;
background: #ffffff;
border: 1px solid #808080;
padding: 0 0.2rem;
}
/* The lone "Quick links" label on the home desktop stays light. */
:root[data-theme="retro"] .section-label {
color: #ffffff;
}
/* ShareX step lists are light-muted by default; black on the silver window. */
:root[data-theme="retro"] .docs-steps {
color: #1a1a1a;
}
/* Each card heading becomes a Win98 title bar with a fake close button.
Headings bleed to the window edges; only the first hugs the top edge so a
multi-step card (e.g. ShareX) reads as stacked group bars, not overlaps. */
:root[data-theme="retro"] .api-content .card > .card-content > h3 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin: 1.5rem -1.5rem 1rem;
padding: 0.35rem 0.5rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 1rem;
font-weight: 700;
}
:root[data-theme="retro"] .api-content .card > .card-content > h3:first-child {
margin-top: -1.5rem;
}
/* The upload endpoint card leads with a method + path row; make that the bar. */
:root[data-theme="retro"] .api-content .endpoint-head {
margin: -1.5rem -1.5rem 1rem;
padding: 0.3rem 0.5rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
}
:root[data-theme="retro"] .endpoint-head .endpoint-path {
color: #ffffff;
}
:root[data-theme="retro"] .api-content .card > .card-content > h3::after,
:root[data-theme="retro"] .api-content .endpoint-head::after {
content: "\2715";
display: grid;
place-items: center;
width: 1.15rem;
height: 1rem;
margin-left: auto;
background: #c0c0c0;
color: #000000;
font-size: 0.7rem;
font-weight: 700;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
/* Body text inside windows reads black, not muted purple. */
:root[data-theme="retro"] .api-content .card p,
:root[data-theme="retro"] .api-content .card h4,
:root[data-theme="retro"] .api-content .field-grid span,
:root[data-theme="retro"] .endpoint-list div em,
:root[data-theme="retro"] .faq-item summary,
:root[data-theme="retro"] .faq-item p {
color: #1a1a1a;
}
/* Sub-labels (Request fields, Example, ...) become small black headers. */
:root[data-theme="retro"] .api-content .card h4 {
text-transform: none;
letter-spacing: 0;
}
/* Endpoint rows are sunken white fields. */
:root[data-theme="retro"] .endpoint-list div {
background: #ffffff;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
/* Home shortcut tiles and quick links: silver windows / sunken white fields. */
:root[data-theme="retro"] .shortcut-card {
background: linear-gradient(to bottom, #ffffff, 6%, #c0c0c0 10%);
background-color: #c0c0c0;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .shortcut-card:hover {
transform: none;
background-color: #d4d0c8;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
}
:root[data-theme="retro"] .shortcut-eyebrow {
color: #000078;
}
:root[data-theme="retro"] .shortcut-title,
:root[data-theme="retro"] .shortcut-sub {
color: #1a1a1a;
}
:root[data-theme="retro"] .link-pill {
background: #ffffff;
border: 1px solid #000000;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
color: #000000;
}
:root[data-theme="retro"] .link-pill span {
background: #000078;
color: #ffffff;
border: 1px solid #000000;
}
/* Colour-coded badges in the classic 16-colour VGA palette, with black
borders so they read like little Win98 toolbar icons. */
:root[data-theme="retro"] .link-pill .tag-get { background: #0000aa; color: #ffffff; }
:root[data-theme="retro"] .link-pill .tag-post { background: #008000; color: #ffffff; }
:root[data-theme="retro"] .link-pill .tag-json { background: #aa00aa; color: #ffffff; }
:root[data-theme="retro"] .link-pill .tag-key { background: #aa5500; color: #ffffff; }
:root[data-theme="retro"] .link-pill .tag-help { background: #00aaaa; color: #000000; }
/* CLI download cards = silver windows. */
:root[data-theme="retro"] .download-card {
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
background-color: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
}
:root[data-theme="retro"] .download-card .download-os,
:root[data-theme="retro"] .download-card p {
color: #1a1a1a;
}
/* FAQ entries are silver windows; the +/- marker stays. */
:root[data-theme="retro"] .faq-item {
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
background-color: #c0c0c0;
border: 1px solid #000000;
box-shadow: var(--shadow);
}
:root[data-theme="retro"] .faq-item summary::after {
color: #000000;
}
/* Copy buttons: stay visible (retro already paints them as silver buttons). */
:root[data-theme="retro"] .code-block .copy-btn {
background: #c0c0c0;
opacity: 1;
}

View File

@@ -0,0 +1,173 @@
.warpbox-popups {
position: fixed;
z-index: 120;
inset-block-start: calc(1rem + env(safe-area-inset-top));
inset-inline-end: calc(1rem + env(safe-area-inset-right));
width: min(26rem, calc(100vw - 2rem));
display: grid;
gap: 0.75rem;
pointer-events: none;
}
.warpbox-popup {
pointer-events: auto;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.25rem);
background: color-mix(in srgb, var(--card) 96%, transparent);
color: var(--card-foreground);
box-shadow: var(--shadow);
opacity: 0;
transform: translateY(-0.55rem);
transition: opacity 160ms ease, transform 160ms ease;
overflow: hidden;
}
.warpbox-popup.is-visible {
opacity: 1;
transform: translateY(0);
}
.warpbox-popup-chrome {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.85rem;
align-items: start;
padding: 0.95rem;
}
.warpbox-popup-icon {
width: 1.6rem;
height: 1.6rem;
display: grid;
place-items: center;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 800;
line-height: 1;
}
.warpbox-popup-warning .warpbox-popup-icon {
background: color-mix(in srgb, var(--primary) 26%, transparent);
color: var(--primary-hover);
}
.warpbox-popup-error .warpbox-popup-icon {
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
}
.warpbox-popup-title {
display: block;
margin: 0 0 0.18rem;
font-size: 0.92rem;
line-height: 1.2;
}
.warpbox-popup-message {
margin: 0;
color: var(--muted-foreground);
font-size: 0.84rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.warpbox-popup-close {
min-height: 1.8rem;
width: 1.8rem;
padding: 0;
border-color: var(--border);
color: var(--muted-foreground);
background: var(--surface-1);
font-size: 1rem;
line-height: 1;
}
.warpbox-popup-close:hover {
color: var(--foreground);
background: var(--surface-1-hover);
}
.warpbox-popup-actions {
display: flex;
justify-content: flex-end;
gap: 0.55rem;
padding: 0 0.95rem 0.95rem;
}
:root[data-theme="retro"] .warpbox-popups {
inset-block-start: 2.65rem;
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
}
:root[data-theme="retro"] .warpbox-popup {
border: 1px solid #000000;
background: #c0c0c0;
color: #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 3px 3px 0 rgba(0, 0, 0, 0.45);
}
:root[data-theme="retro"] .warpbox-popup::before {
content: "Warpbox";
display: block;
margin: 0.18rem 0.18rem 0;
padding: 0.22rem 0.35rem;
background: linear-gradient(to right, #000078, 80%, #0f80cd);
color: #ffffff;
font-size: 0.78rem;
font-weight: 700;
}
:root[data-theme="retro"] .warpbox-popup-error::before {
content: "Warpbox - Error";
}
:root[data-theme="retro"] .warpbox-popup-warning::before {
content: "Warpbox - Warning";
}
:root[data-theme="retro"] .warpbox-popup-info::before {
content: "Warpbox - Info";
}
:root[data-theme="retro"] .warpbox-popup-chrome {
padding: 0.8rem;
}
:root[data-theme="retro"] .warpbox-popup-icon {
border: 1px solid #000000;
background: #ffffff;
color: #000078;
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
}
:root[data-theme="retro"] .warpbox-popup-warning .warpbox-popup-icon {
color: #9a5b00;
}
:root[data-theme="retro"] .warpbox-popup-error .warpbox-popup-icon {
color: #c00000;
}
:root[data-theme="retro"] .warpbox-popup-message {
color: #000000;
}
:root[data-theme="retro"] .warpbox-popup-close {
width: 1.45rem;
height: 1.25rem;
min-height: 1.25rem;
background: #c0c0c0;
color: #000000;
border: 1px solid #000000;
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
font-size: 0.78rem;
font-weight: 700;
}
@media (max-width: 640px) {
.warpbox-popups {
inset-inline: 1rem;
width: auto;
}
}

View File

@@ -48,6 +48,18 @@
width: 100%;
}
.upload-options .form-footer .upload-new-button {
margin-top: -0.25rem;
}
.upload-options .form-footer .upload-new-button[hidden] {
display: none !important;
}
.install-pwa-button[hidden] {
display: none !important;
}
.hero-copy {
text-align: center;
}
@@ -335,10 +347,13 @@ button {
.file-progress-side {
width: min(10rem, 32vw);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.35rem;
align-items: center;
}
.file-progress-percent {
grid-column: 1 / -1;
color: var(--muted-foreground);
font-size: 0.75rem;
text-align: right;
@@ -349,6 +364,85 @@ button {
margin-top: 0;
}
.upload-file-remove {
width: 1.65rem;
height: 1.65rem;
min-height: 1.65rem;
padding: 0;
border-color: var(--border);
border-radius: 999px;
color: var(--muted-foreground);
background: var(--surface-1);
font-size: 1rem;
line-height: 1;
}
.upload-file-remove:hover {
color: var(--foreground);
border-color: var(--primary);
background: var(--surface-1-hover);
}
.upload-file-waiting {
border-color: color-mix(in srgb, var(--primary) 42%, var(--border));
background: color-mix(in srgb, var(--primary) 8%, var(--background));
}
.upload-file-complete .file-progress span {
background: var(--success);
}
.upload-file-state {
grid-column: 1 / -1;
color: var(--muted-foreground);
font-size: 0.72rem;
text-align: right;
}
.upload-file-state-shared {
color: var(--primary);
}
.upload-recovery-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 72%, transparent);
backdrop-filter: blur(10px);
}
.upload-recovery-modal {
width: min(34rem, 100%);
box-shadow: var(--shadow-lg);
}
.upload-recovery-modal h2 {
margin: 0 0 0.65rem;
font-size: 1.35rem;
}
.upload-recovery-modal p {
margin: 0;
color: var(--muted-foreground);
line-height: 1.55;
}
.upload-recovery-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
margin-top: 1.25rem;
}
@media (max-width: 640px) {
.upload-recovery-actions {
grid-template-columns: 1fr;
}
}
.result-item small,
.download-item small,
.result-item code,

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,425 @@
padding: 2rem 0 3rem;
}
/* ============================================================
API documentation — sidebar layout
============================================================ */
.api-docs {
width: min(74rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
display: grid;
grid-template-columns: 13.5rem minmax(0, 1fr);
gap: 2rem;
align-items: start;
}
.api-sidebar {
position: sticky;
top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.api-sidebar-title {
margin: 0 0 0.75rem;
font-size: 1.15rem;
}
.api-nav {
display: flex;
flex-direction: column;
gap: 0.15rem;
border-left: 1px solid var(--border);
padding-left: 0.3rem;
}
.api-nav-link {
display: block;
padding: 0.45rem 0.7rem;
border-radius: calc(var(--radius) - 0.3rem);
color: var(--muted-foreground);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
line-height: 1.2;
transition: background 0.12s ease, color 0.12s ease;
}
.api-nav-link:hover {
background: var(--muted);
color: var(--foreground);
}
.api-nav-link.is-active {
background: color-mix(in srgb, var(--primary) 16%, transparent);
color: var(--foreground);
}
.api-sidebar-meta {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.8rem;
}
.api-sidebar-meta a {
color: var(--muted-foreground);
}
/* --- Panels: only one visible at a time --- */
.api-content {
min-width: 0;
}
.doc-panel {
display: none;
outline: none;
}
.doc-panel.is-active {
display: block;
animation: doc-fade 0.18s ease;
}
@keyframes doc-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.panel-head {
max-width: 46rem;
margin-bottom: 1.5rem;
}
.panel-head h2 {
margin: 0;
font-size: 1.5rem;
}
.panel-head .lead {
margin: 0.6rem 0 0;
color: var(--muted-foreground);
font-size: 0.95rem;
line-height: 1.6;
}
.api-content .card + .card,
.api-content .quickstart {
margin-top: 1rem;
}
.api-content h3 {
margin: 0;
font-size: 1.05rem;
}
.api-content h4 {
margin: 1.4rem 0 0;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted-foreground);
}
.api-content .card p {
margin: 0.65rem 0 0;
color: var(--muted-foreground);
font-size: 0.9rem;
line-height: 1.6;
}
.api-content code {
color: var(--foreground);
}
.api-content .field-grid p {
margin: 0;
}
.section-label {
margin: 1.75rem 0 0.75rem !important;
font-size: 0.8rem !important;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--muted-foreground);
}
/* --- Home shortcuts --- */
.shortcut-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.shortcut-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 1rem;
border: 1px solid var(--border);
border-left: 3px solid var(--accent-c, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 94%, transparent);
text-decoration: none;
transition: border-color 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
}
.shortcut-card:hover {
border-color: var(--accent-c, var(--ring));
transform: translateY(-2px);
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent-c, var(--ring)) 22%, transparent);
}
/* Per-card accent. Each home shortcut owns a colour, echoed by its eyebrow,
left edge, and hover glow. */
.accent-blue { --accent-c: #3b82f6; }
.accent-green { --accent-c: #22c55e; }
.accent-violet { --accent-c: #8b5cf6; }
.accent-amber { --accent-c: #f59e0b; }
.shortcut-eyebrow {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent-c, var(--primary));
}
.shortcut-title {
font-size: 1rem;
font-weight: 650;
color: var(--foreground);
}
.shortcut-sub {
font-size: 0.82rem;
color: var(--muted-foreground);
}
.link-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 0.5rem;
}
.link-pill {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.85rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.2rem);
background: var(--card);
color: var(--foreground);
font-size: 0.88rem;
text-decoration: none;
transition: border-color 0.12s ease;
}
.link-pill:hover {
border-color: var(--ring);
}
.link-pill span {
flex: none;
min-width: 2.6rem;
text-align: center;
padding: 0.15rem 0.35rem;
border-radius: 0.3rem;
background: var(--muted);
color: var(--muted-foreground);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.03em;
}
/* Colour-coded tags on the home quick links (and reusable elsewhere). Tinted
background plus a saturated label so they read as accents, not loud chips. */
.link-pill .tag-get { background: color-mix(in srgb, #3b82f6 22%, transparent); color: #93c5fd; }
.link-pill .tag-post { background: color-mix(in srgb, #22c55e 22%, transparent); color: #86efac; }
.link-pill .tag-json { background: color-mix(in srgb, #8b5cf6 24%, transparent); color: #c4b5fd; }
.link-pill .tag-key { background: color-mix(in srgb, #eab308 24%, transparent); color: #fde047; }
.link-pill .tag-help { background: color-mix(in srgb, #06b6d4 24%, transparent); color: #67e8f9; }
/* --- Code blocks with copy button --- */
.code-block {
position: relative;
margin: 0;
}
.code-block .copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.4rem;
background: color-mix(in srgb, var(--card) 80%, transparent);
color: var(--muted-foreground);
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.code-block:hover .copy-btn,
.code-block .copy-btn:focus-visible {
opacity: 1;
}
.code-block .copy-btn:hover {
color: var(--foreground);
border-color: var(--ring);
}
/* --- Endpoint blocks --- */
.endpoint-head {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.endpoint-path {
font-size: 0.95rem;
font-weight: 600;
}
.method {
flex: none;
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 0.35rem;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.04em;
color: #fff;
}
.method-get { background: #2563eb; }
.method-post { background: #16a34a; }
.method-put { background: #d97706; }
.endpoint-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0 0;
}
.endpoint-list div {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.3rem);
background: var(--background);
}
.endpoint-list div code {
font-size: 0.82rem;
word-break: break-all;
}
.endpoint-list div em {
margin-left: auto;
color: var(--muted-foreground);
font-size: 0.8rem;
font-style: normal;
}
/* --- CLI download cards --- */
.download-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.download-card {
padding: 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 94%, transparent);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.download-card .download-os {
font-size: 1.05rem;
font-weight: 650;
color: var(--foreground);
}
.download-card p {
margin: 0;
color: var(--muted-foreground);
font-size: 0.88rem;
}
.download-card .button {
margin-top: auto;
}
/* --- FAQ --- */
.faq-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.faq-item {
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.2rem);
background: color-mix(in srgb, var(--card) 94%, transparent);
padding: 0 1rem;
}
.faq-item summary {
padding: 0.9rem 0;
cursor: pointer;
font-weight: 600;
color: var(--foreground);
list-style: none;
position: relative;
padding-right: 1.5rem;
}
.faq-item summary::-webkit-details-marker {
display: none;
}
.faq-item summary::after {
content: "+";
position: absolute;
right: 0.1rem;
top: 50%;
transform: translateY(-50%);
color: var(--muted-foreground);
font-size: 1.1rem;
}
.faq-item[open] summary::after {
content: "\2212";
}
.faq-item p {
margin: 0 0 0.95rem;
color: var(--muted-foreground);
font-size: 0.9rem;
line-height: 1.6;
}
.docs-header {
max-width: 44rem;
}
@@ -63,42 +482,19 @@
grid-column: 1 / -1;
}
.endpoint-list,
.field-grid {
display: grid;
gap: 0.65rem;
margin: 1rem 0 0;
}
.endpoint-list div,
.field-grid {
min-width: 0;
}
.endpoint-list div {
display: grid;
grid-template-columns: 7rem minmax(0, 1fr);
gap: 0.75rem;
align-items: baseline;
}
.endpoint-list dt,
.endpoint-list dd {
margin: 0;
min-width: 0;
}
.endpoint-list dt,
.field-grid span {
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 700;
}
.endpoint-list dd code {
display: block;
}
.docs-steps {
margin: 0.85rem 0 0;
padding-left: 1.1rem;

View File

@@ -196,49 +196,65 @@
}
.bar-chart {
display: flex;
align-items: stretch;
display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr));
align-items: end;
gap: 0.4rem;
height: 180px;
min-height: 13rem;
margin-top: 1.25rem;
padding-top: 0.5rem;
}
.bar-chart-col {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
align-items: center;
align-items: stretch;
gap: 0.35rem;
height: 100%;
}
.bar-chart-track {
width: 100%;
max-width: 2.2rem;
min-height: 0;
display: flex;
align-items: flex-end;
justify-content: center;
flex: 1 1 auto;
width: 100%;
max-width: 1.8rem;
height: 150px;
margin: 0 auto;
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
border-radius: 0.45rem 0.45rem 0 0;
background: linear-gradient(180deg, transparent, color-mix(in srgb, var(--border) 55%, transparent));
overflow: hidden;
}
.bar-chart-bar {
display: block;
width: 100%;
min-height: 0;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, var(--primary, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
}
.bar-chart-value {
min-height: 1rem;
overflow: hidden;
color: var(--foreground);
font-size: 0.72rem;
font-weight: 650;
line-height: 1;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.bar-chart-label {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.66rem;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -260,6 +276,8 @@
}
.stat-bar-track {
display: block;
width: 100%;
margin-top: 0.35rem;
height: 0.55rem;
border-radius: 999px;
@@ -270,6 +288,7 @@
.stat-bar-fill {
display: block;
height: 100%;
min-width: 0;
border-radius: 999px;
background: var(--primary, #8b5cf6);
}

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

View File

@@ -57,6 +57,44 @@
grid-template-columns: 1fr;
}
.api-docs {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.api-sidebar {
position: static;
top: auto;
}
.api-sidebar-title {
margin-bottom: 0.5rem;
}
.api-nav {
flex-direction: row;
flex-wrap: wrap;
border-left: 0;
padding-left: 0;
gap: 0.35rem;
}
.api-nav-link {
border: 1px solid var(--border);
}
.api-sidebar-meta {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.endpoint-list div em {
margin-left: 0;
width: 100%;
}
.app-sidebar {
position: static;
width: 100%;
@@ -95,6 +133,41 @@
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 {
font-size: 1.65rem;
}
@@ -213,6 +286,54 @@
flex-wrap: wrap;
}
.file-browser-titlebar {
align-items: flex-start;
}
.file-browser-titlebar > div:first-child {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
}
.file-browser {
padding: 0.25rem;
}
.file-open {
grid-template-columns: 2.65rem minmax(0, 1fr);
gap: 0.55rem;
padding: 0.5rem;
}
.file-media {
width: 2.65rem;
height: 2.65rem;
}
.file-size {
display: none;
}
.file-browser.is-list .file-card {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.file-browser.is-list .file-reaction-dock {
justify-content: flex-end;
padding: 0 0.5rem 0.5rem;
}
.file-browser.is-thumbs {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0.5rem;
}
.file-browser.is-thumbs .file-open {
height: 100%;
}
.file-actions,
.file-browser.is-thumbs .file-actions {
width: 100%;
@@ -220,6 +341,16 @@
grid-template-columns: 1fr;
}
.file-reaction-dock {
right: 0.5rem;
bottom: 0.45rem;
}
.reaction-button {
opacity: 1;
transform: none;
}
.file-progress-side {
width: 100%;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

BIN
backend/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,6 @@
/* TEAM */
Built by: Danlegt
/* SITE */
Language: English
Software: Warp Box

View File

@@ -5,6 +5,17 @@
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) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
@@ -26,6 +37,9 @@
if (!text || !button) {
return;
}
if (typeof text === "string" && (text.startsWith("/") || /^https?:\/\//i.test(text))) {
text = window.Warpbox.absoluteURL(text);
}
await window.Warpbox.writeClipboard(text);
const previous = button.textContent;
button.textContent = copiedLabel;

View File

@@ -0,0 +1,43 @@
(function () {
let installPrompt = null;
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => {
/* Service workers are progressive enhancement here. */
});
});
}
window.addEventListener("beforeinstallprompt", (event) => {
const button = document.querySelector("[data-install-pwa]");
if (!button) {
return;
}
event.preventDefault();
installPrompt = event;
button.hidden = false;
button.addEventListener("click", async () => {
if (!installPrompt) {
return;
}
button.disabled = true;
try {
await installPrompt.prompt();
await installPrompt.userChoice;
} finally {
installPrompt = null;
button.hidden = true;
button.disabled = false;
}
}, { once: true });
});
window.addEventListener("appinstalled", () => {
const button = document.querySelector("[data-install-pwa]");
if (button) {
button.hidden = true;
}
installPrompt = null;
});
})();

View File

@@ -0,0 +1,174 @@
(function () {
const DEFAULT_DURATION = 6200;
const VARIANTS = ["info", "warning", "error"];
const GENERIC_ERROR_MESSAGE = "Something went wrong on this page. Please try again in a moment.";
window.Warpbox = window.Warpbox || {};
let lastGlobalErrorAt = 0;
function ensureRegion() {
let region = document.querySelector("[data-warpbox-popups]");
if (region) {
return region;
}
region = document.createElement("div");
region.className = "warpbox-popups";
region.setAttribute("data-warpbox-popups", "");
region.setAttribute("aria-live", "polite");
region.setAttribute("aria-atomic", "false");
document.body.append(region);
return region;
}
function normalizeOptions(options, message) {
if (typeof options === "string") {
options = { message: options };
} else {
options = options || {};
}
if (message) {
options.message = message;
}
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
return {
variant,
title: options.title || defaultTitle(variant),
message: options.message || "",
duration: Number.isFinite(options.duration) ? options.duration : DEFAULT_DURATION,
actions: Array.isArray(options.actions) ? options.actions : [],
};
}
function defaultTitle(variant) {
if (variant === "error") {
return "Error";
}
if (variant === "warning") {
return "Warning";
}
return "Info";
}
function notify(options, message) {
const config = normalizeOptions(options, message);
const region = ensureRegion();
const popup = document.createElement("section");
popup.className = "warpbox-popup warpbox-popup-" + config.variant;
popup.setAttribute("role", config.variant === "error" ? "alert" : "status");
const chrome = document.createElement("div");
chrome.className = "warpbox-popup-chrome";
const icon = document.createElement("span");
icon.className = "warpbox-popup-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
const body = document.createElement("div");
body.className = "warpbox-popup-body";
const title = document.createElement("strong");
title.className = "warpbox-popup-title";
title.textContent = config.title;
const text = document.createElement("p");
text.className = "warpbox-popup-message";
text.textContent = config.message;
body.append(title, text);
const close = document.createElement("button");
close.type = "button";
close.className = "warpbox-popup-close";
close.setAttribute("aria-label", "Dismiss notification");
close.textContent = "x";
close.addEventListener("click", () => dismiss(popup));
chrome.append(icon, body, close);
popup.append(chrome);
if (config.actions.length > 0) {
const actions = document.createElement("div");
actions.className = "warpbox-popup-actions";
config.actions.forEach((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "button " + (action.kind === "primary" ? "button-primary" : "button-outline");
button.textContent = action.label || "Action";
button.addEventListener("click", () => {
if (typeof action.onClick === "function") {
action.onClick();
}
if (action.dismiss !== false) {
dismiss(popup);
}
});
actions.append(button);
});
popup.append(actions);
}
region.append(popup);
window.requestAnimationFrame(() => popup.classList.add("is-visible"));
let timer = null;
if (config.duration > 0) {
timer = window.setTimeout(() => dismiss(popup), config.duration);
}
return {
element: popup,
close: function closePopup() {
if (timer) {
window.clearTimeout(timer);
}
dismiss(popup);
},
};
}
function dismiss(popup) {
if (!popup || popup.dataset.closing === "true") {
return;
}
popup.dataset.closing = "true";
popup.classList.remove("is-visible");
window.setTimeout(() => popup.remove(), 180);
}
window.Warpbox.notify = notify;
window.Warpbox.info = function info(message, options) {
return notify({ ...(options || {}), variant: "info", message });
};
window.Warpbox.warning = function warning(message, options) {
return notify({ ...(options || {}), variant: "warning", message });
};
window.Warpbox.error = function error(message, options) {
return notify({ ...(options || {}), variant: "error", message });
};
function showGlobalError() {
const now = Date.now();
if (now - lastGlobalErrorAt < 2500) {
return;
}
lastGlobalErrorAt = now;
notify({
variant: "error",
title: "Page error",
message: GENERIC_ERROR_MESSAGE,
duration: 9000,
});
}
window.addEventListener("error", function (event) {
if (event && event.target && event.target !== window) {
return;
}
showGlobalError();
});
window.addEventListener("unhandledrejection", function () {
showGlobalError();
});
})();

View File

@@ -0,0 +1,299 @@
(function () {
const VARIANTS = ["info", "warning", "error"];
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
window.Warpbox = window.Warpbox || {};
let dialogIdCounter = 0;
function defaultTitle(variant) {
if (variant === "error") {
return "Error";
}
if (variant === "warning") {
return "Warning";
}
return "Info";
}
function normalizeOptions(options, message) {
if (typeof options === "string") {
options = { message: options };
} else {
options = options || {};
}
if (message) {
options.message = message;
}
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
return {
variant,
title: options.title || defaultTitle(variant),
message: options.message || "",
body: options.body || null,
actions: Array.isArray(options.actions) ? options.actions : [],
dismissible: options.dismissible !== false,
closable: options.closable !== false,
onClose: typeof options.onClose === "function" ? options.onClose : null,
};
}
function focusableElements(container) {
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
}
function dialog(options, message) {
const config = normalizeOptions(options, message);
const previouslyFocused = document.activeElement;
dialogIdCounter += 1;
const titleId = "warpbox-dialog-title-" + dialogIdCounter;
const overlay = document.createElement("div");
overlay.className = "warpbox-dialog-overlay";
const card = document.createElement("div");
card.className = "warpbox-dialog warpbox-dialog-" + config.variant;
card.setAttribute("role", config.variant === "error" ? "alertdialog" : "dialog");
card.setAttribute("aria-modal", "true");
card.setAttribute("aria-labelledby", titleId);
card.setAttribute("tabindex", "-1");
const head = document.createElement("div");
head.className = "warpbox-dialog-head";
const icon = document.createElement("span");
icon.className = "warpbox-dialog-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
const title = document.createElement("h2");
title.id = titleId;
title.className = "warpbox-dialog-title";
title.textContent = config.title;
head.append(icon, title);
if (config.closable) {
const close = document.createElement("button");
close.type = "button";
close.className = "warpbox-dialog-close";
close.setAttribute("aria-label", "Close dialog");
close.textContent = "x";
close.addEventListener("click", () => closeDialog());
head.append(close);
}
const body = document.createElement("div");
body.className = "warpbox-dialog-body";
if (config.message) {
const text = document.createElement("p");
text.className = "warpbox-dialog-message";
text.textContent = config.message;
body.append(text);
}
if (config.body) {
const nodes = Array.isArray(config.body) ? config.body : [config.body];
nodes.forEach((node) => {
if (node instanceof Node) {
body.append(node);
}
});
}
card.append(head, body);
let autofocusTarget = null;
if (config.actions.length > 0) {
const actions = document.createElement("div");
actions.className = "warpbox-dialog-actions";
config.actions.forEach((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "button " + (action.kind === "primary" ? "button-primary" : action.kind === "ghost" ? "button-ghost" : "button-outline");
button.textContent = action.label || "OK";
button.addEventListener("click", () => {
if (typeof action.onClick === "function") {
action.onClick();
}
if (action.dismiss !== false) {
closeDialog();
}
});
if (action.autofocus) {
autofocusTarget = button;
}
actions.append(button);
});
card.append(actions);
}
overlay.append(card);
document.body.append(overlay);
document.documentElement.classList.add("warpbox-dialog-open");
window.requestAnimationFrame(() => {
overlay.classList.add("is-visible");
(autofocusTarget || card).focus();
});
function handleKeydown(event) {
if (event.key === "Escape") {
if (config.dismissible) {
event.preventDefault();
closeDialog();
}
return;
}
if (event.key !== "Tab") {
return;
}
const focusable = focusableElements(card);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
function handleOverlayClick(event) {
if (config.dismissible && event.target === overlay) {
closeDialog();
}
}
document.addEventListener("keydown", handleKeydown, true);
overlay.addEventListener("click", handleOverlayClick);
let closed = false;
function closeDialog() {
if (closed) {
return;
}
closed = true;
document.removeEventListener("keydown", handleKeydown, true);
overlay.removeEventListener("click", handleOverlayClick);
overlay.classList.remove("is-visible");
document.documentElement.classList.remove("warpbox-dialog-open");
window.setTimeout(() => overlay.remove(), 180);
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
previouslyFocused.focus();
}
if (config.onClose) {
config.onClose();
}
}
return {
element: overlay,
close: closeDialog,
};
}
window.Warpbox.dialog = dialog;
window.Warpbox.alertDialog = function alertDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
dialog({
...config,
message: typeof message === "string" ? message : config.message,
actions: [{ label: config.okLabel || "OK", kind: "primary", autofocus: true }],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
resolve();
},
});
});
};
window.Warpbox.confirmDialog = function confirmDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
let settled = false;
function settle(value) {
if (settled) {
return;
}
settled = true;
resolve(value);
}
dialog({
...config,
message: typeof message === "string" ? message : config.message,
actions: [
{ label: config.cancelLabel || "Cancel", kind: "outline", autofocus: true, onClick: () => settle(false) },
{ label: config.confirmLabel || "Confirm", kind: "primary", onClick: () => settle(true) },
],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
settle(false);
},
});
});
};
window.Warpbox.promptDialog = function promptDialog(message, options) {
const config = (typeof options === "object" && options) || {};
return new Promise((resolve) => {
let settled = false;
function settle(value) {
if (settled) {
return;
}
settled = true;
resolve(value);
}
const field = document.createElement("input");
field.type = config.inputType || "text";
field.className = "warpbox-dialog-field";
if (config.placeholder) {
field.placeholder = config.placeholder;
}
if (typeof config.value === "string") {
field.value = config.value;
}
let controller = null;
field.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
settle(field.value);
if (controller) {
controller.close();
}
}
});
controller = dialog({
...config,
message: typeof message === "string" ? message : config.message,
body: field,
actions: [
{ label: config.cancelLabel || "Cancel", kind: "outline", onClick: () => settle(null) },
{ label: config.okLabel || "OK", kind: "primary", onClick: () => settle(field.value) },
],
onClose: () => {
if (typeof config.onClose === "function") {
config.onClose();
}
settle(null);
},
});
window.requestAnimationFrame(() => field.focus());
});
};
})();

View File

@@ -1,33 +1,50 @@
(function () {
const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
const previewActions = document.querySelectorAll("[data-preview-action]");
const fileContextMenu = document.querySelector("[data-file-context-menu]");
const fileBrowserWindow = document.querySelector("[data-file-browser-window]");
let ctrlCopyMode = false;
let contextFile = null;
const contextMenuCloseDistance = 80;
const viewStorageKey = "warpbox.fileBrowser.view";
if (fileBrowser) {
applySavedFileBrowserPreferences();
viewButtons.forEach((button) => {
button.addEventListener("click", () => {
const view = button.getAttribute("data-view-button");
fileBrowser.classList.toggle("is-list", view === "list");
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
setFileBrowserView(view);
savePreference(viewStorageKey, view);
});
});
if (previewImages) {
previewImages.addEventListener("click", () => {
fileBrowser.classList.toggle("images-only");
previewImages.classList.toggle("is-active");
});
}
}
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) => {
const card = event.target.closest("[data-file-context]");
if (!card) {
@@ -107,7 +124,7 @@
}
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]");
if (!label) {
return;
@@ -147,11 +164,11 @@
return true;
}
if (action === "copy-preview") {
await window.Warpbox.writeClipboard(file.previewURL);
await window.Warpbox.writeClipboard(window.Warpbox.absoluteURL(file.previewURL));
return true;
}
if (action === "copy-download") {
await window.Warpbox.writeClipboard(file.downloadURL);
await window.Warpbox.writeClipboard(window.Warpbox.absoluteURL(file.downloadURL));
return true;
}
if (action === "download") {
@@ -188,4 +205,40 @@
y >= rect.top - contextMenuCloseDistance &&
y <= rect.bottom + contextMenuCloseDistance;
}
function applySavedFileBrowserPreferences() {
const savedView = readPreference(viewStorageKey);
setFileBrowserView(savedView === "list" ? "list" : "thumbs");
}
function setFileBrowserView(view) {
const normalized = view === "thumbs" ? "thumbs" : "list";
fileBrowser.classList.toggle("is-list", normalized === "list");
fileBrowser.classList.toggle("is-thumbs", normalized === "thumbs");
if (fileBrowserWindow) {
fileBrowserWindow.classList.toggle("is-list-view", normalized === "list");
fileBrowserWindow.classList.toggle("is-icon-view", normalized === "thumbs");
}
viewButtons.forEach((item) => {
const active = item.getAttribute("data-view-button") === normalized;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function readPreference(key) {
try {
return window.localStorage.getItem(key);
} catch (_) {
return "";
}
}
function savePreference(key, value) {
try {
window.localStorage.setItem(key, value);
} catch (_) {
// LocalStorage can be unavailable in private or locked-down browsers.
}
}
})();

View File

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

View File

@@ -0,0 +1,50 @@
(function () {
const shareButtons = document.querySelectorAll("[data-share-box]");
if (shareButtons.length === 0) {
return;
}
shareButtons.forEach((button) => {
const label = button.querySelector("[data-share-box-label]") || button;
const shareData = {
title: button.dataset.shareTitle || document.title,
text: button.dataset.shareText || "",
url: window.Warpbox.absoluteURL(button.dataset.shareUrl || window.location.href),
};
const canShare = typeof navigator.share === "function" && (!navigator.canShare || navigator.canShare(shareData));
label.textContent = canShare ? "Share" : "Copy Link";
button.setAttribute("aria-label", canShare ? "Share this box" : "Copy box link");
button.addEventListener("click", async () => {
if (canShare) {
try {
await navigator.share(shareData);
return;
} catch (error) {
if (error && error.name === "AbortError") {
return;
}
}
}
await copyShareURL(button, label, shareData.url, canShare);
});
});
async function copyShareURL(button, label, url, shareMode) {
try {
await window.Warpbox.writeClipboard(url);
const previous = label.textContent;
label.textContent = "Copied";
window.setTimeout(() => {
label.textContent = shareMode ? "Share" : "Copy Link";
}, 1400);
} catch (error) {
if (window.Warpbox && typeof window.Warpbox.error === "function") {
window.Warpbox.error("The share link could not be copied.", {
title: "Copy failed",
});
}
}
}
})();

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
(function () {
const root = document.querySelector("[data-api-docs]");
if (!root) {
return;
}
const panels = Array.from(root.querySelectorAll("[data-doc-panel]"));
const navLinks = Array.from(root.querySelectorAll("[data-doc-link]"));
const DEFAULT = "home";
function activate(name, focus) {
let matched = false;
panels.forEach((panel) => {
const on = panel.dataset.docPanel === name;
panel.classList.toggle("is-active", on);
if (on) {
matched = true;
}
});
if (!matched) {
return false;
}
root.querySelectorAll(".api-nav-link").forEach((link) => {
link.classList.toggle(
"is-active",
link.getAttribute("href") === "#" + name
);
});
if (focus) {
const panel = root.querySelector('[data-doc-panel="' + name + '"]');
if (panel) {
panel.focus({ preventScroll: true });
}
}
return true;
}
// Resolve the current hash to a panel. The hash can point at a panel id
// (e.g. #endpoints) or at any element inside a panel (e.g. #ep-upload),
// letting FAQ answers deep-link straight into the reference.
function resolveHash(focus) {
const id = (location.hash || "").slice(1);
if (!id) {
activate(DEFAULT, focus);
return;
}
const target = document.getElementById(id);
if (!target) {
activate(DEFAULT, focus);
return;
}
const panel = target.closest("[data-doc-panel]");
const name = panel ? panel.dataset.docPanel : DEFAULT;
activate(name, focus && target === panel);
if (panel && target !== panel) {
// Scroll the deep-linked element into view once its panel is visible.
window.requestAnimationFrame(() => {
target.scrollIntoView({ block: "start", behavior: "smooth" });
});
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
window.addEventListener("hashchange", () => resolveHash(true));
navLinks.forEach((link) => {
link.addEventListener("click", () => {
// hashchange handles activation; this keeps top-level nav clicks snappy.
if (link.getAttribute("href") === location.hash) {
resolveHash(true);
}
});
});
// Add a copy button to every code block.
root.querySelectorAll(".code-block").forEach((block) => {
const pre = block.querySelector("pre");
if (!pre) {
return;
}
const button = document.createElement("button");
button.type = "button";
button.className = "copy-btn";
button.textContent = "Copy";
button.setAttribute("aria-label", "Copy code");
button.addEventListener("click", () => {
window.Warpbox.copyText(pre.innerText.trim(), button, "Copied");
});
block.appendChild(button);
});
resolveHash(false);
})();

View File

@@ -0,0 +1,130 @@
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method === "POST" && url.origin === self.location.origin && url.pathname === "/share-target") {
event.respondWith(handleShareTarget(event.request));
}
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
event.waitUntil((async () => {
const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
if ("focus" in client) {
await client.focus();
if ("navigate" in client) {
await client.navigate(url);
}
return;
}
}
if (clients.openWindow) {
await clients.openWindow(url);
}
})());
});
const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_PREFIX = "/__warpbox_share_target__/";
const LATEST_KEY = SHARE_PREFIX + "latest";
async function handleShareTarget(request) {
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
try {
const formData = await request.formData();
const files = collectSharedFiles(formData);
const cache = await caches.open(SHARE_CACHE);
const metadata = {
id,
title: stringValue(formData.get("title")),
text: stringValue(formData.get("text")),
url: stringValue(formData.get("url")),
createdAt: new Date().toISOString(),
files: [],
};
await deletePreviousShare(cache);
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const key = SHARE_PREFIX + "file/" + encodeURIComponent(id) + "/" + index;
metadata.files.push({
key,
name: file.name || "shared-file",
type: file.type || "application/octet-stream",
size: file.size || 0,
lastModified: file.lastModified || Date.now(),
});
await cache.put(key, new Response(file, {
headers: {
"Content-Type": file.type || "application/octet-stream",
"Cache-Control": "no-store",
},
}));
}
await cache.put(LATEST_KEY, jsonResponse(metadata));
await cache.put(SHARE_PREFIX + "meta/" + encodeURIComponent(id), jsonResponse(metadata));
} catch (error) {
await storeShareError(id, error);
}
return Response.redirect("/?share-target=1&share-id=" + encodeURIComponent(id), 303);
}
function collectSharedFiles(formData) {
const files = [];
["files", "file", "sharex"].forEach((name) => {
formData.getAll(name).forEach((value) => {
if (value instanceof File && value.size > 0) {
files.push(value);
}
});
});
return files;
}
function stringValue(value) {
return typeof value === "string" ? value : "";
}
function jsonResponse(payload) {
return new Response(JSON.stringify(payload), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
async function storeShareError(id, error) {
const cache = await caches.open(SHARE_CACHE);
await cache.put(LATEST_KEY, jsonResponse({
id,
error: error && error.message ? error.message : "Shared files could not be staged.",
createdAt: new Date().toISOString(),
files: [],
}));
}
async function deletePreviousShare(cache) {
const previous = await cache.match(LATEST_KEY);
if (!previous) {
return;
}
let metadata = null;
try {
metadata = await previous.json();
} catch (error) {
metadata = null;
}
for (const file of metadata && metadata.files ? metadata.files : []) {
if (file.key) {
await cache.delete(file.key);
}
}
if (metadata && metadata.id) {
await cache.delete(SHARE_PREFIX + "meta/" + encodeURIComponent(metadata.id));
}
await cache.delete(LATEST_KEY);
}

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