22 Commits

Author SHA1 Message Date
78bf3ef11b style: remove hyphens from compound adjectives in comments and messages
Remove hyphens from compound adjectives such as "logged-in", "one-time", "password-protected", "full-height", "multi-file", and "S3-compatible" in comments, test error messages, and UI labels to improve readability and consistency.
2026-06-16 01:34:13 +03:00
78b767a4a2 feat(upload): add pause and cancel controls for active uploads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m3s
- Add CSS grid layout for upload-active-actions and hidden state
- Implement JavaScript logic for pausing and cancelling uploads with confirmation
- Add test to verify home page includes upload control elements
2026-06-16 01:17:32 +03:00
dc4aee8ca2 fix: stage zip downloads to temp file and improve file serving headers
Write zip to a temporary file before serving to enable correct content-length, range requests, and proper cache-control headers. Additionally, handle negative object sizes by falling back to file metadata for content-length.
2026-06-15 21:52:33 +03:00
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
166 changed files with 8651 additions and 20787 deletions

1
.gitignore vendored
View File

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

111
PLANS.md
View File

@@ -1,111 +0,0 @@
# 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.

View File

@@ -357,3 +357,9 @@ bbolt database and JSON logs always remain local under `./data/db` and `./data/l
The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter 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. caching for CSS/JS, and gzip compression for compressible responses.
## AI Usage
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.
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

@@ -68,7 +68,7 @@ func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
} }
} }
if !foundOwned { if !foundOwned {
t.Fatalf("logged-in upload did not store owner id %q", user.ID) t.Fatalf("logged in upload did not store owner id %q", user.ID)
} }
} }
@@ -674,7 +674,7 @@ func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
body := response.Body.String() body := response.Body.String()
header := body[:strings.Index(body, "<main")] header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, "Dashboard") || strings.Contains(header, "Sign in") || strings.Contains(header, "Health") { if !strings.Contains(header, "Dashboard") || strings.Contains(header, "Sign in") || strings.Contains(header, "Health") {
t.Fatalf("api header did not reflect logged-in state: %s", body) t.Fatalf("api header did not reflect logged in state: %s", body)
} }
} }
@@ -775,7 +775,7 @@ func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
} }
body := response.Body.String() body := response.Body.String()
if !strings.Contains(body, `style="height: 150px"`) { if !strings.Contains(body, `style="height: 150px"`) {
t.Fatalf("admin overview did not render a full-height pixel bar: %s", body) 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=`) { 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) t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)

View File

@@ -398,7 +398,7 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
statusBars := []adminStatBar{ statusBars := []adminStatBar{
{Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)}, {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: "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)}, {Label: "password protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
} }
return adminOverview{ return adminOverview{
@@ -1934,7 +1934,7 @@ func (a *App) storageConfigFromForm(r *http.Request, provider string) services.S
func adminStorageProviderOptions() []adminStorageProviderView { func adminStorageProviderOptions() []adminStorageProviderView {
return []adminStorageProviderView{ return []adminStorageProviderView{
{Provider: services.StorageProviderS3, Label: "S3 Bucket", Description: "Generic S3-compatible object storage.", Icon: "cloud"}, {Provider: services.StorageProviderS3, Label: "S3 Bucket", Description: "Generic S3 compatible object storage.", Icon: "cloud"},
{Provider: services.StorageProviderContabo, Label: "Contabo Object Storage", Description: "Contabo COS with TLS and path-style lookup locked on.", Icon: "cloud"}, {Provider: services.StorageProviderContabo, Label: "Contabo Object Storage", Description: "Contabo COS with TLS and path-style lookup locked on.", Icon: "cloud"},
{Provider: services.StorageProviderSFTP, Label: "SFTP", Description: "SSH file transfer to a server or NAS.", Icon: "database"}, {Provider: services.StorageProviderSFTP, Label: "SFTP", Description: "SSH file transfer to a server or NAS.", Icon: "database"},
{Provider: services.StorageProviderSMB, Label: "Samba / SMB", Description: "Windows share or network attached storage.", Icon: "folder"}, {Provider: services.StorageProviderSMB, Label: "Samba / SMB", Description: "Windows share or network attached storage.", Icon: "folder"},

View File

@@ -59,7 +59,7 @@ func (a *App) ShareXAnonymousConfig(w http.ResponseWriter, r *http.Request) {
"RequestURL": a.cfg.BaseURL + "/api/v1/upload", "RequestURL": a.cfg.BaseURL + "/api/v1/upload",
"Headers": map[string]string{ "Headers": map[string]string{
"Accept": "application/json", "Accept": "application/json",
// Group a multi-file selection (sent as back-to-back requests) into // Group a multiple file selection (sent as back to back requests) into
// one box. Remove this header for one box per file. // one box. Remove this header for one box per file.
uploadBatchHeader: "sharex", uploadBatchHeader: "sharex",
}, },

View File

@@ -54,6 +54,8 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
func (a *App) RegisterRoutes(mux *http.ServeMux) { func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home) mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs) mux.HandleFunc("GET /api", a.APIDocs)
mux.HandleFunc("GET /service-worker.js", a.ServiceWorker)
mux.HandleFunc("POST /share-target", a.ShareTargetFallback)
mux.HandleFunc("GET /register", a.Register) mux.HandleFunc("GET /register", a.Register)
mux.HandleFunc("POST /register", a.RegisterPost) mux.HandleFunc("POST /register", a.RegisterPost)
mux.HandleFunc("GET /login", a.Login) mux.HandleFunc("GET /login", a.Login)
@@ -125,14 +127,17 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox) mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
// GET variant so ShareX (which issues a GET to the configured DeletionURL) // GET variant so ShareX (which issues a GET to the configured DeletionURL)
// can delete a box via its secret one-time delete token. // can delete a box via its secret one time delete token.
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip) mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile) mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview)
mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /robots.txt", a.RobotsTxt) mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML) mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)

View File

@@ -160,7 +160,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
} }
// CreateUserToken mints a new personal access token and renders the account // CreateUserToken mints a new personal access token and renders the account
// page with the one-time plaintext shown. The secret is never recoverable after // page with the one time plaintext shown. The secret is never recoverable after
// this response. // this response.
func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) { func (a *App) CreateUserToken(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r) user, ok := a.requireUser(w, r)

View File

@@ -15,6 +15,7 @@ import (
"time" "time"
"warpbox.dev/backend/libs/helpers" "warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
@@ -40,12 +41,17 @@ type fileView struct {
ID string ID string
Name string Name string
Size string Size string
SizeBytes int64
ContentType string ContentType string
PreviewKind string PreviewKind string
URL string URL string
DownloadURL string DownloadURL string
ThumbnailURL string ThumbnailURL string
SceneURL string
ArchiveURL string
HasThumbnail bool HasThumbnail bool
HasScene bool
HasArchive bool
IconURL string IconURL string
IconRetroURL string IconRetroURL string
ReactURL string ReactURL string
@@ -53,6 +59,8 @@ type fileView struct {
ReactionMore int ReactionMore int
Reacted bool Reacted bool
Processing bool Processing bool
Failed bool
Error string
} }
type reactionView struct { type reactionView struct {
@@ -103,14 +111,17 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
} }
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 { if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
if box.Files[0].Processing { file := box.Files[0]
if file.Processing {
http.Error(w, "file is still processing", http.StatusAccepted) http.Error(w, "file is still processing", http.StatusAccepted)
return return
} }
a.serveFileContent(w, r, box, box.Files[0], false) if shouldServeRawSocialMedia(file) {
a.logger.Info("single-file box served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", box.Files[0].ID)...) 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 return
} }
}
visitorID := a.reactionVisitorID(w, r) visitorID := a.reactionVisitorID(w, r)
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID) reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
if err != nil { if err != nil {
@@ -130,16 +141,28 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST") expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
title := "Shared files on Warpbox" title := "Shared files on Warpbox"
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel) description := fmt.Sprintf("%d file%s shared via Warpbox | Expires %s.", len(box.Files), plural(len(box.Files)), expiresLabel)
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
imageType := "image/jpeg"
if !locked && len(box.Files) == 1 && !box.Files[0].Processing {
file := box.Files[0]
view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size)
title = file.Name
description = fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
ogImage = socialImageURL(r, box, file, view)
imageAlt = fmt.Sprintf("Download card for %s", file.Name)
imageType = socialImageType(file)
}
if locked && box.Obfuscate { if locked && box.Obfuscate {
title = "Protected Warpbox link" title = "Protected Warpbox link"
description = "This shared box is password protected." description = "This shared box is password protected."
} }
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID)) pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
// All user uploads are private/temporary noindex by default. // All user uploads are private/temporary. noindex by default.
robots := web.RobotsNone robots := web.RobotsNone
a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
@@ -148,7 +171,8 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
CanonicalURL: pageURL, CanonicalURL: pageURL,
Robots: robots, Robots: robots,
ImageURL: ogImage, ImageURL: ogImage,
ImageAlt: fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files))), ImageAlt: imageAlt,
ImageType: imageType,
Data: downloadPageData{ Data: downloadPageData{
Box: boxView{ID: box.ID}, Box: boxView{ID: box.ID},
Files: files, Files: files,
@@ -171,6 +195,43 @@ func plural(n int) string {
return "s" return "s"
} }
func shouldServeRawSocialMedia(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video"
}
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
if strings.TrimSpace(contentType) == "" {
contentType = "file"
}
return fmt.Sprintf("%s %s. Open to preview or download. Expires %s.", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
}
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
if file.PreviewKind == "image" {
return absoluteURL(r, view.DownloadURL+"?inline=1")
}
if file.PreviewKind == "video" && view.HasThumbnail {
return absoluteURL(r, view.ThumbnailURL)
}
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
}
func socialImageType(file services.File) string {
if file.PreviewKind == "image" {
return file.ContentType
}
return "image/jpeg"
}
func socialOGType(file services.File) string {
switch file.PreviewKind {
case "video":
return "video.other"
default:
return "website"
}
}
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) { func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r) box, file, ok := a.loadFileForRequest(w, r)
if !ok { if !ok {
@@ -183,21 +244,50 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
http.Error(w, "file is still processing", http.StatusAccepted) http.Error(w, "file is still processing", http.StatusAccepted)
return 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.serveFileContent(w, r, box, file, false)
a.logger.Info("file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...) 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 return
} }
view := a.fileView(box, file) view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size) fileSize := helpers.FormatBytes(file.Size)
title := file.Name title := file.Name
description := fmt.Sprintf("%s · %s file shared via Warp Box", fileSize, file.ContentType) description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
imageURL := absoluteURL(r, view.ThumbnailURL) imageURL := socialImageURL(r, box, file, view)
imageAlt := fmt.Sprintf("Preview of %s", file.Name) imageAlt := fmt.Sprintf("Download card for %s", file.Name)
ogType := socialOGType(file)
mediaURL := ""
if file.PreviewKind == "video" {
mediaURL = absoluteURL(r, view.DownloadURL+"?inline=1")
}
if locked && box.Obfuscate { if locked && box.Obfuscate {
title = "Protected Warpbox file" title = "Protected Warpbox file"
description = "This shared file is password protected." description = "This shared file is password protected."
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp") imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
imageAlt = "Password protected file on Warp Box" imageAlt = "Password protected file on Warp Box"
ogType = "website"
mediaURL = ""
} }
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID)) pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID))
@@ -207,8 +297,12 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
Description: description, Description: description,
CanonicalURL: pageURL, CanonicalURL: pageURL,
Robots: web.RobotsNone, Robots: web.RobotsNone,
OGType: ogType,
ImageURL: imageURL, ImageURL: imageURL,
ImageAlt: imageAlt, ImageAlt: imageAlt,
ImageType: socialImageType(file),
MediaURL: mediaURL,
MediaType: file.ContentType,
Data: previewPageData{ Data: previewPageData{
Box: boxView{ID: box.ID}, Box: boxView{ID: box.ID},
File: view, File: view,
@@ -234,6 +328,16 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
http.Error(w, "file is still processing", http.StatusAccepted) http.Error(w, "file is still processing", http.StatusAccepted)
return return
} }
if file.ProcessingError != "" {
a.logger.Warn("failed file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4243, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
if services.BoxHasTrouble(box) {
a.logger.Warn("failed box download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4247, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1") a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...) a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...)
@@ -249,9 +353,25 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
a.servePlaceholderThumbnail(w, r) a.servePlaceholderThumbnail(w, r)
return return
} }
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
a.logger.Warn("thumbnail request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4110, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
a.servePlaceholderThumbnail(w, r)
return
}
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file) object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if err != nil { if err != nil {
if thumbnail := a.generateMissingThumbnailForRequest(r, box, file); thumbnail != "" {
file.Thumbnail = thumbnail
object, err = a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
return
}
}
// The thumbnail isn't generated yet (background job pending). Serve the // The thumbnail isn't generated yet (background job pending). Serve the
// placeholder but mark it non-cacheable, otherwise the browser would // placeholder but mark it non-cacheable, otherwise the browser would
// keep showing the placeholder until a hard refresh once the real // keep showing the placeholder until a hard refresh once the real
@@ -266,6 +386,178 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body)) http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
} }
func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if !jobs.NeedsVideoScenes(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
a.servePlaceholderThumbnail(w, r)
return
}
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
a.servePlaceholderThumbnail(w, r)
return
}
object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
if err != nil {
if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" {
file.SceneThumbnail = scene
object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
return
}
}
a.servePlaceholderThumbnail(w, r)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if !jobs.NeedsArchiveListing(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency)
return
}
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
return
}
}
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
}
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return ""
}
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
if err != nil || thumbnail == "" {
if err != nil {
a.logger.Warn("on-demand thumbnail generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4102, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].Thumbnail = thumbnail
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand thumbnail metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4103, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return thumbnail
}
func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string {
if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return ""
}
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file)
if err != nil || scene == "" {
if err != nil {
a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].SceneThumbnail = scene
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return scene
}
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return ""
}
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
if err != nil || listing == "" {
if err != nil {
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
}
return ""
}
for i := range box.Files {
if box.Files[i].ID == file.ID {
box.Files[i].ArchiveListing = listing
box.Files[i].ArchiveListingObjectKey = ""
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return listing
}
func troubleReasonForLog(box services.Box, file services.File) string {
if services.FileHasTrouble(file) {
return file.ProcessingError
}
return services.BoxTroubleReason(box)
}
// servePlaceholderThumbnail serves the fallback image with no-store so the // servePlaceholderThumbnail serves the fallback image with no-store so the
// browser re-requests on the next load and picks up the real thumbnail as soon // browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated. // as it has been generated.
@@ -334,14 +626,21 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
defer object.Body.Close() defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType) w.Header().Set("Content-Type", file.ContentType)
w.Header().Set("Cache-Control", "no-transform")
disposition := "inline"
if attachment { if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name)) disposition = "attachment"
} }
w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name))
if seeker, ok := object.Body.(io.ReadSeeker); ok { if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker) http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else { } else {
if object.Size > 0 { size := object.Size
w.Header().Set("Content-Length", fmt.Sprintf("%d", object.Size)) if size < 0 {
size = file.Size
}
if size >= 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, object.Body) _, _ = io.Copy(w, object.Body)
@@ -352,6 +651,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
} }
} }
func contentDisposition(disposition, name string) string {
filename := cleanDownloadFilename(name)
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename))
}
func cleanDownloadFilename(name string) string {
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
clean = filepath.Base(clean)
if clean == "" || clean == "." || clean == "/" {
return "download"
}
return clean
}
func asciiFilenameFallback(name string) string {
var fallback strings.Builder
for _, char := range name {
switch {
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';':
fallback.WriteByte('_')
case char <= 0x7e:
fallback.WriteRune(char)
default:
fallback.WriteByte('_')
}
}
clean := strings.TrimSpace(fallback.String())
if clean == "" {
return "download"
}
return clean
}
func readSeekCloser(source io.ReadCloser) io.ReadSeeker { func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
data, err := io.ReadAll(source) data, err := io.ReadAll(source)
if err != nil { if err != nil {
@@ -378,15 +710,61 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
http.Error(w, "password required", http.StatusUnauthorized) http.Error(w, "password required", http.StatusUnauthorized)
return return
} }
for _, file := range box.Files {
w.Header().Set("Content-Type", "application/zip") if file.Processing {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip")) http.Error(w, "file is still processing", http.StatusAccepted)
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if err := a.uploadService.WriteZip(w, box); err != nil {
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
return 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
}
tempDir := filepath.Join(a.cfg.DataDir, "tmp", "downloads")
if err := os.MkdirAll(tempDir, 0o700); err != nil {
a.logger.Error("zip staging directory creation failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
archive, err := os.CreateTemp(tempDir, "warpbox-*.zip")
if err != nil {
a.logger.Error("zip staging file creation failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
archivePath := archive.Name()
defer func() {
archive.Close()
if err := os.Remove(archivePath); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to remove staged zip", "source", "download", "severity", "warn", "box_id", box.ID, "error", err.Error())
}
}()
if err := a.uploadService.WriteZip(archive, box); err != nil {
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
stat, err := archive.Stat()
if err != nil {
a.logger.Error("staged zip stat failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
name := "warpbox-" + box.ID + ".zip"
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Cache-Control", "no-transform")
w.Header().Set("Content-Disposition", contentDisposition("attachment", name))
http.ServeContent(w, r, name, stat.ModTime(), archive)
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error()) a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
} }
@@ -404,12 +782,17 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
ID: file.ID, ID: file.ID,
Name: file.Name, Name: file.Name,
Size: helpers.FormatBytes(file.Size), Size: helpers.FormatBytes(file.Size),
SizeBytes: file.Size,
ContentType: file.ContentType, ContentType: file.ContentType,
PreviewKind: file.PreviewKind, PreviewKind: file.PreviewKind,
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "", 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), IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro), IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
@@ -417,6 +800,8 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
ReactionMore: reactionOverflowCount(reactionViews), ReactionMore: reactionOverflowCount(reactionViews),
Reacted: reacted, Reacted: reacted,
Processing: file.Processing, Processing: file.Processing,
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
Error: troubleReasonForLog(box, file),
} }
} }

View File

@@ -34,7 +34,7 @@ type mimeRule struct {
icon fileIcon icon fileIcon
} }
// fileIconSet is the loaded icon map: an extension lookup plus content-type // 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. // rules and a fallback. It is built once at startup from icon-map.json.
type fileIconSet struct { type fileIconSet struct {
byExt map[string]fileIcon byExt map[string]fileIcon
@@ -43,7 +43,7 @@ type fileIconSet struct {
} }
// loadFileIcons reads static/file-icons/icon-map.json and indexes it by // loadFileIcons reads static/file-icons/icon-map.json and indexes it by
// extension and content type so icons can be assigned at render time. // extension and Content-Type so icons can be assigned at render time.
func loadFileIcons(staticDir string) (*fileIconSet, error) { func loadFileIcons(staticDir string) (*fileIconSet, error) {
data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json")) data, err := os.ReadFile(filepath.Join(staticDir, "file-icons", "icon-map.json"))
if err != nil { if err != nil {
@@ -111,8 +111,8 @@ func validateFileIconPath(staticDir, theme, name string) error {
} }
// lookup resolves a file's icon from its name (extension) first, falling back to // 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 // its Content-Type, then to the default icon. Extension wins because stored
// content types are often the generic application/octet-stream. // Content-Types are often the generic application/octet-stream.
func (s *fileIconSet) lookup(name, contentType string) fileIcon { func (s *fileIconSet) lookup(name, contentType string) fileIcon {
if s == nil { if s == nil {
return fileIcon{} return fileIcon{}

View File

@@ -15,7 +15,7 @@ func (a *App) RobotsTxt(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `User-agent: * fmt.Fprintf(w, `User-agent: *
Allow: / Allow: /
# Private routes do not crawl # Private routes. do not crawl
Disallow: /admin/ Disallow: /admin/
Disallow: /api/ Disallow: /api/
Disallow: /app/ Disallow: /app/
@@ -23,6 +23,8 @@ Disallow: /account/
Disallow: /d/*/f/*/download Disallow: /d/*/f/*/download
Disallow: /d/*/zip Disallow: /d/*/zip
Disallow: /d/*/thumb/ Disallow: /d/*/thumb/
Disallow: /d/*/scene/
Disallow: /d/*/archive/
Disallow: /d/*/og-image.jpg Disallow: /d/*/og-image.jpg
Disallow: /d/*/unlock Disallow: /d/*/unlock
Disallow: /d/*/manage/ Disallow: /d/*/manage/

View File

@@ -2,6 +2,8 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt"
"image" "image"
"image/color" "image/color"
"image/draw" "image/draw"
@@ -11,10 +13,19 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
xdraw "golang.org/x/image/draw" xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
) )
// Open Graph image dimensions recommended for large summary cards // Open Graph image dimensions recommended for large summary cards
@@ -74,6 +85,77 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
a.serveOGImage(w, r, renderCollage(thumbs)) a.serveOGImage(w, r, renderCollage(thumbs))
} }
// FileOGImage renders a branded card for files that should not be served as raw
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
box, file, ok := a.loadFileForRequest(w, r)
if !ok {
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
a.serveOGImage(w, r, a.ogPlaceholder())
return
}
if jobs.NeedsArchiveListing(file) {
if listing, ok := a.archiveListingForOG(r, box, file); ok {
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
return
}
}
icon := a.ogFileIcon(file)
a.serveOGImage(w, r, a.renderFileCard(file, icon))
}
type ogArchiveListing struct {
Name string `json:"name"`
Type string `json:"type"`
FileCount int `json:"fileCount"`
FolderCount int `json:"folderCount"`
UncompressedSize uint64 `json:"uncompressedSize"`
Root *ogArchiveNode `json:"root"`
}
type ogArchiveNode struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Dir bool `json:"dir"`
Icon string `json:"icon,omitempty"`
Items []*ogArchiveNode `json:"items,omitempty"`
}
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
}
}
if err != nil {
return ogArchiveListing{}, false
}
defer object.Body.Close()
var listing ogArchiveListing
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
return ogArchiveListing{}, false
}
if listing.Root == nil {
return ogArchiveListing{}, false
}
return listing, true
}
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) { func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
var buf bytes.Buffer var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil { if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
@@ -115,6 +197,326 @@ func (a *App) ogPlaceholder() image.Image {
return canvas return canvas
} }
func (a *App) ogFileIcon(file services.File) image.Image {
if a.fileIcons == nil {
return nil
}
icon := a.fileIcons.lookup(file.Name, file.ContentType)
if icon.Retro == "" {
return nil
}
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return nil
}
return img
}
func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
panel := image.Rect(70, 72, ogImageWidth-70, ogImageHeight-72)
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
titleFace := a.ogFont(44, true)
bodyFace := a.ogFont(28, false)
metaFace := a.ogFont(24, false)
buttonFace := a.ogFont(26, true)
if icon != nil {
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
} else {
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
}
titleLines := wrapOGText(file.Name, titleFace, 850)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
}
y := 156
for _, line := range titleLines {
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
y += 52
}
size := helpers.FormatBytes(file.Size)
typeLabel := strings.TrimSpace(file.ContentType)
if typeLabel == "" {
typeLabel = "application/octet-stream"
}
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
info := fileCardInfo(file)
for i, line := range wrapOGText(info, metaFace, 900) {
if i >= 2 {
break
}
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
}
button := image.Rect(110, 474, 430, 548)
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
return canvas
}
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
titleFace := a.ogFont(36, true)
bodyFace := a.ogFont(22, false)
treeFace := a.ogFont(19, false)
labelFace := a.ogFont(17, true)
icon := a.ogFileIcon(file)
if icon != nil {
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
} else {
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
}
title := listing.Name
if strings.TrimSpace(title) == "" {
title = file.Name
}
titleLines := wrapOGText(title, titleFace, 820)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
}
y := 106
for _, line := range titleLines {
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
y += 42
}
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
treePanel := image.Rect(104, 214, 1096, 548)
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
rows := archiveOGRows(listing.Root, 13)
rowY := treePanel.Min.Y + 64
for _, row := range rows {
if row.Ellipsis {
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
break
}
x := treePanel.Min.X + 20 + row.Depth*28
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
name := row.Name
if row.Dir {
name += "/"
}
maxNameWidth := treePanel.Max.X - x - 170
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
if !row.Dir {
size := formatOGArchiveBytes(row.Size)
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
}
rowY += 23
}
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
return canvas
}
type archiveOGRow struct {
Name string
Icon string
Size uint64
Dir bool
Depth int
Ellipsis bool
}
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
rows := make([]archiveOGRow, 0, limit+1)
truncated := false
var walk func(items []*ogArchiveNode, depth int)
walk = func(items []*ogArchiveNode, depth int) {
for _, item := range items {
if len(rows) >= limit {
truncated = true
return
}
icon := item.Icon
if item.Dir {
icon = "folder"
}
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
if item.Dir {
walk(item.Items, depth+1)
}
}
}
if root != nil {
walk(root.Items, 0)
}
if truncated {
rows = append(rows, archiveOGRow{Ellipsis: true})
}
return rows
}
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
c := archiveOGIconColor(icon)
rect := image.Rect(x, y, x+20, y+20)
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
if icon == "folder" {
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
}
}
func archiveOGIconColor(icon string) color.RGBA {
switch icon {
case "folder":
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
case "img":
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
case "vid":
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
case "aud":
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
case "code":
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
case "arc":
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
default:
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
}
}
func archiveOGTextColor(row archiveOGRow) color.RGBA {
if row.Dir {
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
}
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
}
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
if strings.TrimSpace(listing.Type) != "" {
return listing.Type
}
if strings.TrimSpace(file.ContentType) != "" {
return file.ContentType
}
return "Archive"
}
func formatOGArchiveBytes(size uint64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
value := float64(size) / unit
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
value /= unit
}
return fmt.Sprintf("%.1f PiB", value)
}
func fileCardInfo(file services.File) string {
switch {
case strings.HasPrefix(file.ContentType, "audio/"):
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
case file.ContentType == "text/markdown":
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
case strings.Contains(file.ContentType, "html"):
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
case strings.Contains(file.ContentType, "pdf"):
return "PDF file shared through Warpbox. Open the link to download the original file."
case strings.HasPrefix(file.ContentType, "text/"):
return "Text file shared through Warpbox. Open the link to preview the content or download."
default:
return "File shared through Warpbox. Open the link to preview available details or download the original."
}
}
func (a *App) ogFont(size float64, bold bool) font.Face {
name := "PixeloidSans.ttf"
if bold {
name = "PixeloidSans-Bold.ttf"
}
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
if err != nil {
return basicfont.Face7x13
}
parsed, err := opentype.Parse(data)
if err != nil {
return basicfont.Face7x13
}
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return basicfont.Face7x13
}
return face
}
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
d := font.Drawer{
Dst: dst,
Src: image.NewUniform(c),
Face: face,
Dot: fixed.P(x, y),
}
d.DrawString(text)
}
func wrapOGText(text string, face font.Face, maxWidth int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{text}
}
lines := []string{}
current := words[0]
for _, word := range words[1:] {
next := current + " " + word
if ogTextWidth(face, next) <= maxWidth {
current = next
continue
}
lines = append(lines, current)
current = word
}
lines = append(lines, current)
return lines
}
func trimOGText(text string, face font.Face, maxWidth int) string {
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
text = text[:len(text)-1]
}
return strings.TrimSpace(text) + "..."
}
func ogTextWidth(face font.Face, text string) int {
bounds, _ := font.BoundString(face, text)
return (bounds.Max.X - bounds.Min.X).Ceil()
}
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap. // renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
func renderCollage(thumbs []image.Image) image.Image { func renderCollage(thumbs []image.Image) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight)) canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
@@ -150,7 +552,7 @@ func collageGrid(n int) (cols, rows int) {
} }
// drawCover scales src to completely fill dst, cropping the overflow (centred), // drawCover scales src to completely fill dst, cropping the overflow (centred),
// preserving aspect ratio the CSS object-fit: cover equivalent. // preserving aspect ratio. the CSS object-fit: cover equivalent.
func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) { func drawCover(dst *image.RGBA, cell image.Rectangle, src image.Image) {
b := src.Bounds() b := src.Bounds()
iw, ih := b.Dx(), b.Dy() iw, ih := b.Dx(), b.Dy()

View File

@@ -10,6 +10,7 @@ import (
type homeData struct { type homeData struct {
MaxUploadSize string MaxUploadSize string
MaxUploadBytes int64
LimitSummary string LimitSummary string
Collections []collectionView Collections []collectionView
IsAdmin bool IsAdmin bool
@@ -57,17 +58,18 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
"actor", actor, "actor", actor,
"user_id", user.ID, "user_id", user.ID,
)...) )...)
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin) maxUploadSize, maxUploadBytes, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin) expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files", Title: "Upload your files",
Description: "Upload and share files fast. Drop a file, get a link — private, temporary transfers that expire on your terms.", Description: "Upload and share files quickly. Drop a file, get a link.",
CanonicalURL: absoluteURL(r, "/"), CanonicalURL: absoluteURL(r, "/"),
ImageURL: absoluteURL(r, "/static/og-default.png"), ImageURL: absoluteURL(r, "/static/og-default.png"),
ImageAlt: "Warp Box simple file sharing and fast downloads", ImageAlt: "Warp Box | simple file sharing and fast downloads",
CurrentUser: currentUser, CurrentUser: currentUser,
Data: homeData{ Data: homeData{
MaxUploadSize: maxUploadSize, MaxUploadSize: maxUploadSize,
MaxUploadBytes: maxUploadBytes,
LimitSummary: limitSummary, LimitSummary: limitSummary,
Collections: collections, Collections: collections,
IsAdmin: isAdmin, IsAdmin: isAdmin,
@@ -89,7 +91,7 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser
unlimited = true unlimited = true
case loggedIn: case loggedIn:
maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays
// A negative per-user MaxDays override means unlimited retention. // A negative per user MaxDays override means unlimited retention.
if maxDays < 0 { if maxDays < 0 {
unlimited = true unlimited = true
} }
@@ -155,22 +157,25 @@ func expiryLabel(minutes int) string {
} }
} }
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) { func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, int64, string) {
if isAdmin { if isAdmin {
return "No file size limit", "Admin uploads bypass storage and daily caps." return "No file size limit", -1, "Admin uploads bypass storage and daily caps."
} }
if !loggedIn { if !loggedIn {
if !settings.AnonymousUploadsEnabled { if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files." return "Anonymous uploads disabled", 0, "Sign in to upload files."
} }
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max." return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), services.MegabytesToBytes(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
} }
policy := a.settingsService.EffectivePolicyForUser(settings, user) policy := a.settingsService.EffectivePolicyForUser(settings, user)
maxUpload := a.uploadService.MaxUploadSizeLabel() maxUpload := a.uploadService.MaxUploadSizeLabel()
maxUploadBytes := a.uploadService.MaxUploadSize()
if policy.MaxUploadMB < 0 { if policy.MaxUploadMB < 0 {
maxUpload = "unlimited" maxUpload = "unlimited"
maxUploadBytes = -1
} else if policy.MaxUploadMB > 0 { } else if policy.MaxUploadMB > 0 {
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB) maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
maxUploadBytes = services.MegabytesToBytes(policy.MaxUploadMB)
} }
quota := "unlimited" quota := "unlimited"
if policy.StorageQuotaSet { if policy.StorageQuotaSet {
@@ -180,5 +185,5 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if policy.MaxDays < 0 { if policy.MaxDays < 0 {
expiryLimit = "no expiry limit." expiryLimit = "no expiry limit."
} }
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit return maxUpload, maxUploadBytes, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
} }

View File

@@ -180,7 +180,7 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
if session.Status == services.ResumableStatusCompleted || session.Status == services.ResumableStatusProcessing { if session.Status == services.ResumableStatusCompleted {
result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID) result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID)
if err != nil { 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())...) a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
@@ -191,6 +191,17 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, result) helpers.WriteJSON(w, http.StatusOK, result)
return 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) user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn) settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)

View File

@@ -34,6 +34,17 @@ func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path) http.ServeFile(w, r, path)
} }
func (a *App) ServiceWorker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Service-Worker-Allowed", "/")
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "js", "service-worker.js"))
}
func (a *App) ShareTargetFallback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/?share-target=unsupported", http.StatusSeeOther)
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) { func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))

View File

@@ -1,7 +1,12 @@
package handlers package handlers
import ( import (
"encoding/json"
"net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings"
"testing" "testing"
) )
@@ -24,3 +29,100 @@ func TestSetStaticCacheHeaders(t *testing.T) {
} }
} }
} }
func TestHomeIncludesActiveUploadControls(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
app.Home(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
for _, want := range []string{
`id="upload-active-actions"`,
`id="cancel-upload"`,
`id="pause-upload"`,
`Cancel Upload`,
`Pause Upload`,
} {
if !strings.Contains(response.Body.String(), want) {
t.Fatalf("home page missing %q", want)
}
}
}
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" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"strconv" "strconv"
@@ -53,11 +54,16 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
} }
if err := r.ParseMultipartForm(parseLimit); err != nil { if err := r.ParseMultipartForm(parseLimit); err != nil {
a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...) a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, "upload exceeds the configured upload limit")
return
}
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read") helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return return
} }
files := uploadFiles(r) files := uploadIncomingFiles(r)
totalBytes := totalUploadBytes(files) totalBytes := totalUploadBytes(files)
var ownerID string var ownerID string
var collectionID string var collectionID string
@@ -159,7 +165,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
// uploadGroupWindow are folded into one box. Without the header the behaviour is // uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many // identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting. // boxes were created (1 for a new box, 0 for an append) for usage accounting.
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) { func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []services.IncomingFile, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader)) batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
if batch == "" { if batch == "" {
if enforceBoxLimits { if enforceBoxLimits {
@@ -167,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil return services.UploadResult{}, 0, status, message, nil
} }
} }
result, err := a.uploadService.CreateBox(files, opts) result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil { if err != nil {
return services.UploadResult{}, 0, 0, "", err return services.UploadResult{}, 0, 0, "", err
} }
@@ -188,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow { if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil { if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil { if result, err := a.uploadService.AppendIncomingFiles(entry.boxID, files, opts); err == nil {
// Re-attach the manage/delete URLs from the box's creation so every // Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL. // upload in the batch returns a working deletion URL.
result.ManageURL = entry.manageURL result.ManageURL = entry.manageURL
@@ -204,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
return services.UploadResult{}, 0, status, message, nil return services.UploadResult{}, 0, status, message, nil
} }
} }
result, err := a.uploadService.CreateBox(files, opts) result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
if err != nil { if err != nil {
return services.UploadResult{}, 0, 0, "", err return services.UploadResult{}, 0, 0, "", err
} }
@@ -216,7 +222,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
} }
// batchBoxMatches guards that a batched append only ever touches a box owned by // batchBoxMatches guards that a batched append only ever touches a box owned by
// the same uploader (account for logged-in users, creator IP for anonymous). // the same uploader (account for logged in users, creator IP for anonymous).
func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool { func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool {
if loggedIn { if loggedIn {
return box.OwnerID == user.ID return box.OwnerID == user.ID
@@ -224,13 +230,13 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r) return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
} }
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) { func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []services.IncomingFile, totalBytes int64) (int, string) {
if len(files) == 0 { if len(files) == 0 {
return 0, "" return 0, ""
} }
sizes := make([]int64, 0, len(files)) sizes := make([]int64, 0, len(files))
for _, file := range files { for _, file := range files {
sizes = append(sizes, file.Size) sizes = append(sizes, file.Size())
} }
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes) return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
} }
@@ -244,7 +250,7 @@ func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, log
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB) maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, fileSize := range fileSizes { for _, fileSize := range fileSizes {
if fileSize > maxBytes { if fileSize > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit" return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB)
} }
} }
} }
@@ -378,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
return "ip:" + uploadClientIP(r) return "ip:" + uploadClientIP(r)
} }
func totalUploadBytes(files []*multipart.FileHeader) int64 { func totalUploadBytes(files []services.IncomingFile) int64 {
var total int64 var total int64
for _, file := range files { for _, file := range files {
total += file.Size total += file.Size()
} }
return total return total
} }
@@ -404,13 +410,48 @@ func statusForDownloadError(err error) int {
return http.StatusForbidden return http.StatusForbidden
} }
func uploadFiles(r *http.Request) []*multipart.FileHeader { type namedMultipartFile struct {
header *multipart.FileHeader
name string
}
func (f namedMultipartFile) Name() string {
if strings.TrimSpace(f.name) != "" {
return f.name
}
return f.header.Filename
}
func (f namedMultipartFile) Size() int64 {
return f.header.Size
}
func (f namedMultipartFile) ContentType() string {
return f.header.Header.Get("Content-Type")
}
func (f namedMultipartFile) Open() (io.ReadCloser, error) {
return f.header.Open()
}
func uploadIncomingFiles(r *http.Request) []services.IncomingFile {
if r.MultipartForm == nil { if r.MultipartForm == nil {
return nil return nil
} }
files := make([]*multipart.FileHeader, 0) fileHeaders := r.MultipartForm.File["file"]
files = append(files, r.MultipartForm.File["file"]...) shareXHeaders := r.MultipartForm.File["sharex"]
files = append(files, r.MultipartForm.File["sharex"]...) paths := r.MultipartForm.Value["file_path"]
files := make([]services.IncomingFile, 0, len(fileHeaders)+len(shareXHeaders))
for index, header := range fileHeaders {
name := ""
if index < len(paths) {
name = paths[index]
}
files = append(files, namedMultipartFile{header: header, name: name})
}
for _, header := range shareXHeaders {
files = append(files, namedMultipartFile{header: header})
}
return files return files
} }

View File

@@ -7,11 +7,11 @@ import (
// uploadGroupWindow is how long after a batched upload a follow-up upload with // uploadGroupWindow is how long after a batched upload a follow-up upload with
// the same X-Warpbox-Batch value (and same account/IP) is folded into the same // the same X-Warpbox-Batch value (and same account/IP) is folded into the same
// box. ShareX sends a multi-file selection as separate back-to-back requests; // box. ShareX sends a multiple file selection as separate back to back requests;
// the batch header lets it land them in one box. // the batch header lets it land them in one box.
const uploadGroupWindow = 20 * time.Second const uploadGroupWindow = 20 * time.Second
// uploadBatchHeader is the opt-in request header. Without it, uploads behave // uploadBatchHeader is the opt in request header. Without it, uploads behave
// exactly as before (one box per request). With it, requests sharing the same // exactly as before (one box per request). With it, requests sharing the same
// value (per account/IP) within uploadGroupWindow are grouped into one box. // value (per account/IP) within uploadGroupWindow are grouped into one box.
const uploadBatchHeader = "X-Warpbox-Batch" const uploadBatchHeader = "X-Warpbox-Batch"
@@ -20,7 +20,7 @@ const uploadBatchHeader = "X-Warpbox-Batch"
// can't grow without bound (one key per account/IP + batch value otherwise). // can't grow without bound (one key per account/IP + batch value otherwise).
const uploadGroupPruneInterval = 5 * time.Minute const uploadGroupPruneInterval = 5 * time.Minute
// uploadGrouper tracks the most recent box per batch key so opt-in batched // uploadGrouper tracks the most recent box per batch key so opt in batched
// uploads land in a single box. Each key has its own lock, which also serialises // uploads land in a single box. Each key has its own lock, which also serialises
// that key's concurrent uploads so they append to the same box instead of racing // that key's concurrent uploads so they append to the same box instead of racing
// to create several. // to create several.

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -17,6 +18,7 @@ import (
"time" "time"
"warpbox.dev/backend/libs/config" "warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
@@ -106,7 +108,7 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
} }
} }
func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) { func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
payload := uploadThroughApp(t, app) payload := uploadThroughApp(t, app)
@@ -120,15 +122,19 @@ func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
if response.Code != http.StatusOK { if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
if strings.Contains(response.Body.String(), "Shared files on Warpbox") { body := response.Body.String()
t.Fatalf("social preview bot received HTML download page") 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 response.Body.String() != "hello" { if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
t.Fatalf("social preview body = %q", response.Body.String()) 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 TestSocialPreviewBotGetsRawFilePreview(t *testing.T) { func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
payload := uploadThroughApp(t, app) payload := uploadThroughApp(t, app)
@@ -140,14 +146,204 @@ func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
response := httptest.NewRecorder() response := httptest.NewRecorder()
app.DownloadFile(response, request) 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 { if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
if strings.Contains(response.Body.String(), "preview-title") { if strings.Contains(response.Body.String(), "preview-title") {
t.Fatalf("social preview bot received HTML preview page") 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" { if response.Body.String() != "hello" {
t.Fatalf("social preview body = %q", response.Body.String()) t.Fatalf("body = %q", response.Body.String())
}
if got := response.Header().Get("Content-Length"); got != "5" {
t.Fatalf("Content-Length = %q, want 5", got)
}
if got := response.Header().Get("Cache-Control"); got != "no-transform" {
t.Fatalf("Cache-Control = %q, want no-transform", got)
}
}
func TestZipDownloadIncludesExactContentLength(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadNamedFileThroughApp(t, app, "report.txt", "hello zip")
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/zip", nil)
request.SetPathValue("boxID", payload.BoxID)
response := httptest.NewRecorder()
app.DownloadZip(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if got, want := response.Header().Get("Content-Length"), strconv.Itoa(response.Body.Len()); got != want {
t.Fatalf("Content-Length = %q, want %s", got, want)
}
if got := response.Header().Get("Cache-Control"); got != "no-transform" {
t.Fatalf("Cache-Control = %q, want no-transform", got)
}
archive, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len()))
if err != nil {
t.Fatalf("zip.NewReader returned error: %v", err)
}
if len(archive.File) != 1 || archive.File[0].Name != "report.txt" {
t.Fatalf("unexpected zip files: %+v", archive.File)
}
}
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)
}
} }
} }
@@ -657,6 +853,7 @@ func newTestApp(t *testing.T) (*App, func()) {
t.Fatalf("NewBanService returned error: %v", err) t.Fatalf("NewBanService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() { return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
jobs.WaitForThumbnailJobs()
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
@@ -664,8 +861,12 @@ func newTestApp(t *testing.T) (*App, func()) {
} }
func uploadThroughApp(t *testing.T, app *App) services.UploadResult { func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
}
func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
t.Helper() t.Helper()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello") request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
request.Header.Set("Accept", "application/json") request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
app.Upload(response, request) app.Upload(response, request)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -60,6 +60,9 @@ func shouldSkipGzip(r *http.Request) bool {
} }
path := r.URL.Path path := r.URL.Path
if strings.HasPrefix(path, "/d/") && (strings.HasSuffix(path, "/zip") || strings.HasSuffix(path, "/download")) {
return true
}
switch ext := strings.ToLower(path[strings.LastIndex(path, ".")+1:]); ext { switch ext := strings.ToLower(path[strings.LastIndex(path, ".")+1:]); ext {
case "br", "gz", "zip", "7z", "rar", "jpg", "jpeg", "png", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "mp3", "ogg", "woff", "woff2", "ttf", "otf": case "br", "gz", "zip", "7z", "rar", "jpg", "jpeg", "png", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "mp3", "ogg", "woff", "woff2", "ttf", "otf":
return true return true

View File

@@ -61,3 +61,30 @@ func TestGzipSkipsRangeAndHeadRequests(t *testing.T) {
}) })
} }
} }
func TestGzipSkipsDownloadEndpoints(t *testing.T) {
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "11")
_, _ = io.WriteString(w, "hello world")
}))
for _, path := range []string{
"/d/box/f/file/download",
"/d/box/zip",
} {
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Accept-Encoding", "gzip")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if got := response.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := response.Header().Get("Content-Length"); got != "11" {
t.Fatalf("Content-Length = %q, want 11", got)
}
})
}
}

View File

@@ -9,7 +9,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
header.Set("X-Frame-Options", "DENY") header.Set("X-Frame-Options", "DENY")
header.Set("Referrer-Policy", "strict-origin-when-cross-origin") header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'") header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-src 'self' about:; base-uri 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })

View File

@@ -120,7 +120,7 @@ type Collection struct {
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// APIToken is a long-lived personal access token. Only the SHA-256 hash of the // APIToken is a long lived personal access token. Only the SHA-256 hash of the
// secret is stored; the plaintext is shown to the user exactly once at creation. // secret is stored; the plaintext is shown to the user exactly once at creation.
type APIToken struct { type APIToken struct {
ID string `json:"id"` ID string `json:"id"`
@@ -131,7 +131,7 @@ type APIToken struct {
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
} }
// APITokenResult carries the one-time plaintext alongside the stored token. // APITokenResult carries the one time plaintext alongside the stored token.
type APITokenResult struct { type APITokenResult struct {
Token APIToken Token APIToken
Plaintext string Plaintext string
@@ -907,7 +907,7 @@ func validateUserPolicy(policy UserPolicy) error {
return fmt.Errorf("active box override must be positive or -1 for unlimited") return fmt.Errorf("active box override must be positive or -1 for unlimited")
} }
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 { if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 {
return fmt.Errorf("short-window request override must be positive or -1 for unlimited") return fmt.Errorf("short window request override must be positive or -1 for unlimited")
} }
return nil return nil
} }

View File

@@ -118,7 +118,7 @@ func TestAPITokenLifecycle(t *testing.T) {
if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) { if result.Plaintext == "" || !strings.HasPrefix(result.Plaintext, apiTokenPrefix) {
t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix) t.Fatalf("plaintext = %q, want %q prefix", result.Plaintext, apiTokenPrefix)
} }
// The secret must never be stored in plaintext only its hash. // The secret must never be stored in plaintext. only its hash.
if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext { if strings.Contains(result.Token.TokenHash, result.Plaintext) || result.Token.TokenHash == result.Plaintext {
t.Fatalf("stored token hash leaks the plaintext secret") t.Fatalf("stored token hash leaks the plaintext secret")
} }

View File

@@ -313,7 +313,7 @@ func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
now = now.UTC() now = now.UTC()
var matched BanRecord var matched BanRecord
var matchedKey []byte var matchedKey []byte
// Read-only scan first: the common case (no match) only takes a concurrent // read only scan first: the common case (no match) only takes a concurrent
// read transaction, instead of grabbing the single bbolt write lock on every // read transaction, instead of grabbing the single bbolt write lock on every
// request that flows through the ban middleware. // request that flows through the ban middleware.
err := s.db.View(func(tx *bbolt.Tx) error { err := s.db.View(func(tx *bbolt.Tx) error {

View File

@@ -19,7 +19,7 @@ func ClientIPFromContext(r *http.Request) (string, bool) {
} }
// ClientIP resolves the effective client IP. When trustedProxies is empty, // ClientIP resolves the effective client IP. When trustedProxies is empty,
// forwarded headers are trusted for easy reverse-proxy/container defaults. // forwarded headers are trusted for easy reverse proxy/container defaults.
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string { func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
remoteIP := IPOnly(remoteAddr) remoteIP := IPOnly(remoteAddr)
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) { if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {

View File

@@ -319,7 +319,7 @@ func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (Uplo
} }
box.Files = append(box.Files, File{ box.Files = append(box.Files, File{
ID: fileID, ID: fileID,
Name: filepath.Base(incoming.Name), Name: cleanUploadDisplayName(incoming.Name),
StoredName: storedName, StoredName: storedName,
Size: incoming.Size, Size: incoming.Size,
ContentType: contentType, ContentType: contentType,
@@ -369,19 +369,20 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context,
} }
backend, err := s.storage.Backend(box.StorageBackendID) backend, err := s.storage.Backend(box.StorageBackendID)
if err != nil { if err != nil {
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err return UploadResult{}, err
} }
for i, incoming := range staged { for i, incoming := range staged {
source, err := incoming.Open() source, err := incoming.Open()
if err != nil { if err != nil {
_ = s.markProcessingBoxFailed(box, err)
return UploadResult{}, err return UploadResult{}, err
} }
file := box.Files[i] file := box.Files[i]
if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil { if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil {
source.Close() source.Close()
_ = backend.Delete(context.Background(), file.ObjectKey) _ = backend.Delete(context.Background(), file.ObjectKey)
box.Files[i].ProcessingError = err.Error() _ = s.markProcessingBoxFailed(box, err)
_ = s.saveBoxRecord(box)
return UploadResult{}, err return UploadResult{}, err
} }
source.Close() source.Close()
@@ -406,6 +407,35 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context,
return s.resultForBox(box, ""), nil 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) { func (s *UploadService) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
session, err := s.GetResumableSession(sessionID) session, err := s.GetResumableSession(sessionID)
if err != nil { if err != nil {
@@ -527,7 +557,7 @@ func (s *UploadService) saveResumableSession(session ResumableSession) error {
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) { func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
sessionFiles := make([]ResumableFile, 0, len(files)) sessionFiles := make([]ResumableFile, 0, len(files))
for _, file := range files { for _, file := range files {
file.Name = filepath.Base(strings.TrimSpace(file.Name)) file.Name = cleanUploadDisplayName(file.Name)
if file.Name == "." || file.Name == "" { if file.Name == "." || file.Name == "" {
return nil, fmt.Errorf("file name is required") return nil, fmt.Errorf("file name is required")
} }
@@ -564,7 +594,7 @@ func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts
} }
func resumableFileKey(name string, size int64, fingerprint string) string { func resumableFileKey(name string, size int64, fingerprint string) string {
return strings.TrimSpace(fingerprint) + "|" + filepath.Base(strings.TrimSpace(name)) + "|" + fmt.Sprintf("%d", size) return strings.TrimSpace(fingerprint) + "|" + cleanUploadDisplayName(name) + "|" + fmt.Sprintf("%d", size)
} }
type resumableIncomingFile struct { type resumableIncomingFile struct {

View File

@@ -455,7 +455,7 @@ func (s *SettingsService) validate(settings UploadPolicySettings) error {
return fmt.Errorf("active box limits must be positive") return fmt.Errorf("active box limits must be positive")
} }
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 { if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
return fmt.Errorf("short-window rate limits must be positive") return fmt.Errorf("short window rate limits must be positive")
} }
if settings.ResumableChunkSizeMB <= 0 { if settings.ResumableChunkSizeMB <= 0 {
return fmt.Errorf("resumable chunk size must be positive") return fmt.Errorf("resumable chunk size must be positive")

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

View File

@@ -16,6 +16,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -117,6 +118,8 @@ type Box struct {
Obfuscate bool `json:"obfuscate"` Obfuscate bool `json:"obfuscate"`
CreatorIP string `json:"creatorIp,omitempty"` CreatorIP string `json:"creatorIp,omitempty"`
StorageBackendID string `json:"storageBackendId,omitempty"` StorageBackendID string `json:"storageBackendId,omitempty"`
Trouble bool `json:"trouble,omitempty"`
TroubleReason string `json:"troubleReason,omitempty"`
Files []File `json:"files"` Files []File `json:"files"`
} }
@@ -128,13 +131,48 @@ type File struct {
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"` PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
ArchiveListing string `json:"archiveListing,omitempty"`
ObjectKey string `json:"objectKey,omitempty"` ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
Processing bool `json:"processing,omitempty"` Processing bool `json:"processing,omitempty"`
ProcessingError string `json:"processingError,omitempty"` ProcessingError string `json:"processingError,omitempty"`
UploadedAt time.Time `json:"uploadedAt"` UploadedAt time.Time `json:"uploadedAt"`
} }
func BoxHasTrouble(box Box) bool {
if box.Trouble || strings.TrimSpace(box.TroubleReason) != "" {
return true
}
for _, file := range box.Files {
if FileHasTrouble(file) {
return true
}
}
return false
}
func BoxTroubleReason(box Box) string {
if strings.TrimSpace(box.TroubleReason) != "" {
return box.TroubleReason
}
for _, file := range box.Files {
if strings.TrimSpace(file.ProcessingError) != "" {
return file.ProcessingError
}
}
if box.Trouble {
return "box has failed processing"
}
return ""
}
func FileHasTrouble(file File) bool {
return strings.TrimSpace(file.ProcessingError) != ""
}
type UploadResult struct { type UploadResult struct {
BoxID string `json:"boxId"` BoxID string `json:"boxId"`
BoxURL string `json:"boxUrl"` BoxURL string `json:"boxUrl"`
@@ -268,7 +306,7 @@ func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files
var expiresAt time.Time var expiresAt time.Time
switch { switch {
case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0: case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0:
// "Forever" a date far enough out that the box effectively never // "Forever". a date far enough out that the box effectively never
// expires. No schema change; CanDownload/cleanup keep working as-is. // expires. No schema change; CanDownload/cleanup keep working as-is.
expiresAt = now.AddDate(100, 0, 0) expiresAt = now.AddDate(100, 0, 0)
case opts.ExpiresInMinutes > 0: case opts.ExpiresInMinutes > 0:
@@ -323,7 +361,7 @@ func (s *UploadService) CreateBoxFromIncomingContext(ctx context.Context, files
return s.resultForBox(box, deleteToken), nil return s.resultForBox(box, deleteToken), nil
} }
// AppendFiles adds files to an existing box (used to group a ShareX multi-file // AppendFiles adds files to an existing box (used to group a ShareX multiple file
// selection into a single box). The box keeps its original expiry, password and // selection into a single box). The box keeps its original expiry, password and
// other settings; only the new files are written. // other settings; only the new files are written.
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) { func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
@@ -397,7 +435,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name())) storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
objectKey := boxObjectKey(box.ID, storedName) objectKey := boxObjectKey(box.ID, storedName)
contentType := incoming.ContentType() contentType := incoming.ContentType()
if contentType == "" { if contentType == "" || contentType == "application/octet-stream" {
buffer := make([]byte, 512) buffer := make([]byte, 512)
n, _ := file.Read(buffer) n, _ := file.Read(buffer)
contentType = http.DetectContentType(buffer[:n]) contentType = http.DetectContentType(buffer[:n])
@@ -415,7 +453,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
box.Files = append(box.Files, File{ box.Files = append(box.Files, File{
ID: fileID, ID: fileID,
Name: filepath.Base(incoming.Name()), Name: cleanUploadDisplayName(incoming.Name()),
StoredName: storedName, StoredName: storedName,
Size: incoming.Size(), Size: incoming.Size(),
ContentType: contentType, ContentType: contentType,
@@ -427,6 +465,36 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
return nil return nil
} }
func cleanUploadDisplayName(name string) string {
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
clean = strings.TrimLeft(clean, "/")
clean = path.Clean(clean)
if clean == "." || clean == "/" || clean == "" {
return "download"
}
parts := strings.Split(clean, "/")
safeParts := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "." || part == ".." {
continue
}
part = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f || r == '/' || r == '\\' {
return -1
}
return r
}, part)
if part != "" {
safeParts = append(safeParts, part)
}
}
if len(safeParts) == 0 {
return "download"
}
return strings.Join(safeParts, "/")
}
func (s *UploadService) GetBox(id string) (Box, error) { func (s *UploadService) GetBox(id string) (Box, error) {
var box Box var box Box
err := s.db.View(func(tx *bbolt.Tx) error { err := s.db.View(func(tx *bbolt.Tx) error {
@@ -731,6 +799,12 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
if key := s.ThumbnailObjectKey(box, file); key != "" { if key := s.ThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key) _ = backend.Delete(context.Background(), key)
} }
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
if key := s.ArchiveListingObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
} }
box.Files = append(box.Files[:index], box.Files[index+1:]...) box.Files = append(box.Files[:index], box.Files[index+1:]...)
@@ -818,6 +892,26 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
return boxObjectKey(box.ID, file.Thumbnail) return boxObjectKey(box.ID, file.Thumbnail)
} }
func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
if file.SceneThumbnailObjectKey != "" {
return file.SceneThumbnailObjectKey
}
if file.SceneThumbnail == "" {
return ""
}
return boxObjectKey(box.ID, file.SceneThumbnail)
}
func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string {
if file.ArchiveListingObjectKey != "" {
return file.ArchiveListingObjectKey
}
if file.ArchiveListing == "" {
return ""
}
return boxObjectKey(box.ID, file.ArchiveListing)
}
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) { func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
if file.Processing { if file.Processing {
return StorageObject{}, fmt.Errorf("file is still processing") return StorageObject{}, fmt.Errorf("file is still processing")
@@ -841,6 +935,30 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F
return backend.Get(ctx, key) return backend.Get(ctx, key)
} }
func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
key := s.SceneThumbnailObjectKey(box, file)
if key == "" {
return StorageObject{}, os.ErrNotExist
}
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return StorageObject{}, err
}
return backend.Get(ctx, key)
}
func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
key := s.ArchiveListingObjectKey(box, file)
if key == "" {
return StorageObject{}, os.ErrNotExist
}
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return StorageObject{}, err
}
return backend.Get(ctx, key)
}
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) { func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil { if err != nil {
@@ -910,9 +1028,13 @@ func (s *UploadService) RecordDownload(boxID string) error {
}) })
} }
func (s *UploadService) WriteZip(w io.Writer, box Box) error { func (s *UploadService) WriteZip(w io.Writer, box Box) (err error) {
archive := zip.NewWriter(w) archive := zip.NewWriter(w)
defer archive.Close() defer func() {
if closeErr := archive.Close(); err == nil {
err = closeErr
}
}()
for _, file := range box.Files { for _, file := range box.Files {
object, err := s.OpenFileObject(context.Background(), box, file) object, err := s.OpenFileObject(context.Background(), box, file)
@@ -977,7 +1099,7 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
} }
// The box-level thumbnail points at the most recently added file, so a // The box-level thumbnail points at the most recently added file, so a
// per-file ShareX upload previews the file it just sent. // per file ShareX upload previews the file it just sent.
thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID) thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
if len(files) > 0 { if len(files) > 0 {
thumbnailURL = files[len(files)-1].ThumbnailURL thumbnailURL = files[len(files)-1].ThumbnailURL

View File

@@ -230,6 +230,47 @@ func TestResumableCompleteRejectsMissingChunks(t *testing.T) {
} }
} }
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) { func TestResumablePartialCompleteKeepsOnlyFinishedFiles(t *testing.T) {
service := newTestUploadService(t) service := newTestUploadService(t)
session, err := service.CreateResumableSession([]ResumableFileInput{ session, err := service.CreateResumableSession([]ResumableFileInput{

View File

@@ -23,10 +23,14 @@ type PageData struct {
BaseURL string BaseURL string
CanonicalURL string CanonicalURL string
Robots string Robots string
OGType string
Title string Title string
Description string Description string
ImageURL string ImageURL string
ImageAlt string ImageAlt string
ImageType string
MediaURL string
MediaType string
CurrentYear int CurrentYear int
CurrentUser any CurrentUser any
CSRFToken string CSRFToken string

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"

View File

@@ -18,7 +18,7 @@
--danger: #fb7185; --danger: #fb7185;
--radius: 0.875rem; --radius: 0.875rem;
--shadow: 0 24px 70px rgba(8, 4, 32, 0.6); --shadow: 0 24px 70px rgba(8, 4, 32, 0.6);
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans serif;
--header-bg: rgba(11, 11, 22, 0.68); --header-bg: rgba(11, 11, 22, 0.68);
--body-bg: --body-bg:
radial-gradient(circle at 50% -10%, rgba(139, 92, 246, 0.18), transparent 34rem), radial-gradient(circle at 50% -10%, rgba(139, 92, 246, 0.18), transparent 34rem),
@@ -48,7 +48,7 @@
--danger: #fca5a5; --danger: #fca5a5;
--radius: 0.625rem; --radius: 0.625rem;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.45); --shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans serif;
--header-bg: rgba(9, 9, 11, 0.84); --header-bg: rgba(9, 9, 11, 0.84);
--body-bg: --body-bg:
radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem), radial-gradient(circle at 50% -10%, rgba(82, 82, 91, 0.32), transparent 34rem),
@@ -78,7 +78,7 @@
--danger: #fb4934; --danger: #fb4934;
--radius: 0.65rem; --radius: 0.65rem;
--shadow: 0 24px 70px rgba(0, 0, 0, 0.42); --shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans serif;
--header-bg: rgba(29, 32, 33, 0.86); --header-bg: rgba(29, 32, 33, 0.86);
--body-bg: --body-bg:
radial-gradient(circle at 20% -8%, rgba(215, 153, 33, 0.2), transparent 28rem), radial-gradient(circle at 20% -8%, rgba(215, 153, 33, 0.2), transparent 28rem),
@@ -109,7 +109,7 @@
--danger: #ff2a6d; --danger: #ff2a6d;
--radius: 0.35rem; --radius: 0.35rem;
--shadow: 0 24px 70px rgba(255, 42, 109, 0.16), 0 0 34px rgba(0, 240, 255, 0.12); --shadow: 0 24px 70px rgba(255, 42, 109, 0.16), 0 0 34px rgba(0, 240, 255, 0.12);
--font-sans: "Inter", "Rajdhani", "Orbitron", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-sans: "Inter", "Rajdhani", "Orbitron", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans serif;
--header-bg: rgba(8, 7, 13, 0.86); --header-bg: rgba(8, 7, 13, 0.86);
--body-bg: --body-bg:
radial-gradient(circle at 10% -10%, rgba(255, 242, 0, 0.2), transparent 26rem), radial-gradient(circle at 10% -10%, rgba(255, 242, 0, 0.2), transparent 26rem),
@@ -145,7 +145,7 @@
inset 1px 1px 0 #ffffff, inset 1px 1px 0 #ffffff,
inset -2px -2px 0 #808080, inset -2px -2px 0 #808080,
inset 2px 2px 0 #dfdfdf; inset 2px 2px 0 #dfdfdf;
--font-sans: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif; --font-sans: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans serif;
--header-bg: #c0c0c0; --header-bg: #c0c0c0;
--body-bg: #000000; --body-bg: #000000;
--surface-1: #ffffff; --surface-1: #ffffff;

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

@@ -1,16 +1,16 @@
/* /*
* "retro" theme flourishes — modelled on danlegt.com. * "retro" theme flourishes. Modelled on danlegt.com.
* *
* Windows 98 chrome over a black pixel-star desktop, PixeloidSans pixel font, * Windows 98 chrome over a black pixel-star desktop, PixeloidSans pixel font,
* crisp (non-antialiased, pixelated) rendering. Scoped entirely to * crisp (non-antialiased, pixelated) rendering. Scoped entirely to
* :root[data-theme="retro"] so it never touches the other themes. * :root[data-theme="retro"] so it never touches the other themes.
* *
* CSP-safe: external stylesheet + self-hosted fonts only (font-src 'self'), * CSP-safe: external stylesheet + self hosted fonts only (font-src 'self'),
* no inline styles, no remote assets. The starfield is pure CSS so we don't * no inline styles, no remote assets. The starfield is pure CSS so we don't
* depend on img-src for a background gif. * depend on img-src for a background gif.
*/ */
/* Self-hosted pixel fonts (mirrored locally GGBotNet PixeloidSans is free, /* self hosted pixel fonts (mirrored locally. GGBotNet PixeloidSans is free,
PixelOperator is CC0). ------------------------------------------------- */ PixelOperator is CC0). ------------------------------------------------- */
@font-face { @font-face {
font-family: "PixeloidSans"; font-family: "PixeloidSans";
@@ -50,7 +50,7 @@
image-rendering: pixelated; image-rendering: pixelated;
} }
/* Square everything Win98 had no rounded corners. */ /* Square everything. Win98 had no rounded corners. */
:root[data-theme="retro"] *, :root[data-theme="retro"] *,
:root[data-theme="retro"] *::before, :root[data-theme="retro"] *::before,
:root[data-theme="retro"] *::after { :root[data-theme="retro"] *::after {
@@ -152,16 +152,16 @@
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs /* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
are styled as their own Win98 controls below, so they're excluded here. */ are styled as their own Win98 controls below, so they're excluded here. */
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill) {
color: #0000ee; color: #0000ee;
text-decoration: underline; text-decoration: underline;
} }
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):visited {
color: #551a8b; color: #551a8b;
} }
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover { :root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):hover {
color: #ee0000; color: #ee0000;
} }
@@ -655,7 +655,7 @@
padding: 0; padding: 0;
} }
:root[data-theme="retro"] .view-toolbar .icon-button svg { :root[data-theme="retro"] .view-toolbar .icon-button .svg-icon {
margin: 0; margin: 0;
display: block; display: block;
} }
@@ -736,8 +736,288 @@
align-self: start; align-self: start;
} }
:root[data-theme="retro"] .file-type, :root[data-theme="retro"] .file type,
:root[data-theme="retro"] .file-size, :root[data-theme="retro"] .file-size,
:root[data-theme="retro"] .file-main small { :root[data-theme="retro"] .file-main small {
color: inherit; 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,16 @@
width: 100%; width: 100%;
} }
.upload-active-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.upload-active-actions[hidden] {
display: none !important;
}
.upload-options .form-footer .upload-new-button { .upload-options .form-footer .upload-new-button {
margin-top: -0.25rem; margin-top: -0.25rem;
} }
@@ -56,6 +66,10 @@
display: none !important; display: none !important;
} }
.install-pwa-button[hidden] {
display: none !important;
}
.hero-copy { .hero-copy {
text-align: center; text-align: center;
} }
@@ -395,6 +409,10 @@ button {
text-align: right; text-align: right;
} }
.upload-file-state-shared {
color: var(--primary);
}
.upload-recovery-overlay { .upload-recovery-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View File

@@ -15,6 +15,658 @@
text-align: center; text-align: center;
} }
.preview-view {
width: min(72rem, calc(100% - 2rem));
min-height: auto;
padding-block: clamp(2rem, 7vh, 4.5rem);
display: block;
}
.preview-card {
width: 100%;
margin: 0 auto;
text-align: left;
}
.preview-card .card-content {
padding: clamp(1rem, 2.4vw, 1.5rem);
}
.preview-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.preview-title-group {
min-width: 0;
}
.preview-header .file-name {
margin: 0;
font-size: clamp(1.35rem, 2.4vw, 2rem);
line-height: 1.12;
}
.preview-header .download-subtitle {
margin: 0.45rem 0 0;
}
.preview-header > .button {
flex: 0 0 auto;
padding-inline: 1rem;
overflow: visible;
}
.preview-header > .button svg {
flex: 0 0 auto;
}
[data-theme="retro"] .preview-header > .button-primary:active {
padding-right: calc(1rem - 1px);
}
.preview-window {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in srgb, var(--card) 94%, transparent), color-mix(in srgb, var(--background) 92%, transparent));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28);
}
.preview-window-titlebar {
min-height: 3rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.72rem 0.9rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--muted) 62%, transparent);
}
.preview-window-titlebar > div:first-child {
min-width: 0;
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.preview-window-titlebar strong {
font-size: 0.92rem;
}
.preview-window-titlebar span {
overflow: hidden;
color: var(--muted-foreground);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-window-tools {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
}
.preview-fullscreen-button {
appearance: none;
min-height: 2rem;
padding: 0.35rem 0.7rem;
border: 1px solid color-mix(in srgb, var(--border) 82%, var(--primary));
border-radius: calc(var(--radius) - 0.35rem);
background: color-mix(in srgb, var(--muted) 74%, transparent);
color: var(--foreground);
font: inherit;
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.preview-fullscreen-button:hover {
background: color-mix(in srgb, var(--primary) 18%, var(--muted));
}
.preview-fullscreen-button[hidden] {
display: none !important;
}
.preview-window-actions {
display: inline-flex;
gap: 0.35rem;
}
.preview-window-actions span {
width: 0.72rem;
height: 0.72rem;
border: 1px solid color-mix(in srgb, var(--border) 75%, var(--foreground));
border-radius: 999px;
background: var(--muted);
}
.preview-tabs {
display: flex;
gap: 0.35rem;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 78%, transparent);
}
.preview-tabs[hidden] {
display: none !important;
}
.preview-tab {
appearance: none;
min-height: 2rem;
padding: 0.35rem 0.7rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 0.35rem);
background: transparent;
color: var(--muted-foreground);
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.preview-tab:hover,
.preview-tab.is-active {
border-color: color-mix(in srgb, var(--border) 82%, var(--primary));
background: color-mix(in srgb, var(--muted) 78%, transparent);
color: var(--foreground);
}
.preview-stage {
overflow: hidden;
min-height: clamp(18rem, 64vh, 38rem);
display: grid;
place-items: center;
background:
linear-gradient(45deg, color-mix(in srgb, var(--muted) 18%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in srgb, var(--muted) 18%, transparent) 25%, transparent 25%),
color-mix(in srgb, var(--background) 88%, #000);
background-position: 0 0, 0.5rem 0.5rem;
background-size: 1rem 1rem;
}
.preview-stage > * {
grid-area: 1 / 1;
}
.preview-stage > img,
.preview-stage > video {
max-height: clamp(18rem, 64vh, 38rem);
width: 100%;
object-fit: contain;
}
.preview-stage > audio {
width: min(42rem, calc(100% - 2rem));
}
.default-preview,
.large-preview-gate {
width: min(26rem, calc(100% - 2rem));
display: grid;
place-items: center;
gap: 0.9rem;
padding: 2rem;
color: var(--muted-foreground);
text-align: center;
}
.default-preview img {
width: 5.5rem;
height: 5.5rem;
object-fit: contain;
}
.default-preview div {
min-width: 0;
display: grid;
gap: 0.25rem;
}
.default-preview strong {
max-width: 100%;
overflow: hidden;
color: var(--foreground);
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.default-preview span {
font-size: 0.86rem;
}
.large-preview-gate {
border: 1px solid color-mix(in srgb, var(--border) 82%, var(--danger));
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 92%, #000);
}
.large-preview-gate strong {
color: var(--foreground);
font-size: 1rem;
}
.large-preview-gate p {
margin: 0;
line-height: 1.45;
}
.large-preview-gate div {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.native-preview {
width: 100%;
height: clamp(18rem, 64vh, 38rem);
}
.video-scenes-preview {
object-fit: contain;
background: color-mix(in srgb, var(--background) 88%, black 12%);
}
.native-audio-preview {
align-self: center;
width: min(42rem, calc(100% - 2rem));
height: auto;
}
.archive-browser-preview {
width: 100%;
height: clamp(18rem, 64vh, 38rem);
overflow: auto;
background: color-mix(in srgb, var(--card) 86%, black 14%);
color: var(--foreground);
}
.archive-browser-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, black 8%);
}
.archive-browser-header strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 0.98rem;
}
.archive-browser-header span {
color: var(--muted-foreground);
font-size: 0.82rem;
}
.archive-tree {
padding: 0.6rem 0.8rem 1rem;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.45;
}
.archive-node {
min-width: max-content;
}
.archive-node-row {
display: grid;
grid-template-columns: 1.25rem 1.45rem minmax(12rem, 1fr) auto;
align-items: center;
gap: 0.45rem;
min-height: 2.1rem;
padding: 0.18rem 0.45rem;
border-radius: 6px;
color: var(--foreground);
}
.archive-node-row:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.archive-folder > summary {
cursor: pointer;
list-style: none;
}
.archive-folder > summary::-webkit-details-marker {
display: none;
}
.archive-chevron,
.archive-chevron-spacer,
.archive-file-icon {
display: inline-grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
}
.archive-chevron {
color: var(--muted-foreground);
transition: transform 140ms ease, color 140ms ease;
}
.archive-folder[open] > summary .archive-chevron {
transform: rotate(90deg);
color: var(--accent);
}
.archive-chevron svg {
width: 1.18rem;
height: 1.18rem;
}
.archive-file-icon {
color: var(--muted-foreground);
}
.archive-svg-icon,
.archive-retro-icon {
display: inline-grid;
place-items: center;
width: 100%;
height: 100%;
}
.archive-retro-icon {
display: none;
object-fit: contain;
image-rendering: pixelated;
}
.archive-file-icon svg,
.archive-chevron svg {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.archive-file-icon-folder {
color: var(--accent);
}
.archive-file-icon-folder svg {
fill: color-mix(in srgb, var(--accent) 18%, transparent);
}
.archive-file-icon-img {
color: #67e8f9;
}
.archive-file-icon-vid {
color: #f9a8d4;
}
.archive-file-icon-aud {
color: #86efac;
}
.archive-file-icon-code {
color: #c4b5fd;
}
.archive-file-icon-arc {
color: #fcd34d;
}
.archive-file-icon-txt {
color: #f8fafc;
}
[data-theme="retro"] .archive-browser-preview {
border: 1px solid #000000;
background: #ffffff;
color: #000000;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #ffffff;
}
[data-theme="retro"] .archive-browser-header {
border-bottom: 1px solid #808080;
background: #c0c0c0;
color: #000000;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #ffffff;
}
[data-theme="retro"] .archive-browser-header span {
color: #404040;
}
[data-theme="retro"] .archive-tree {
background: #ffffff;
font-family: "PixelOperatorMono", "Courier New", monospace;
}
[data-theme="retro"] .archive-node-row {
min-height: 1.8rem;
border-radius: 0;
color: #000000;
}
[data-theme="retro"] .archive-node-row:hover {
background: #000078;
color: #ffffff;
}
[data-theme="retro"] .archive-node-size {
color: #404040;
}
[data-theme="retro"] .archive-node-row:hover .archive-node-size {
color: #ffffff;
}
[data-theme="retro"] .archive-chevron {
color: #000000;
}
[data-theme="retro"] .archive-folder[open] > summary .archive-chevron {
color: #000078;
}
[data-theme="retro"] .archive-node-row:hover .archive-chevron,
[data-theme="retro"] .archive-node-row:hover .archive-file-icon {
color: #ffffff;
}
[data-theme="retro"] .archive-chevron svg {
width: 1.35rem;
height: 1.35rem;
stroke-width: 2.4;
}
[data-theme="retro"] .archive-file-icon {
width: 1.45rem;
height: 1.45rem;
color: #000000;
}
[data-theme="retro"] .archive-svg-icon {
display: none;
}
[data-theme="retro"] .archive-retro-icon {
display: block;
}
[data-theme="retro"] .archive-browser-empty,
[data-theme="retro"] .archive-browser-legacy {
color: #000000;
background: #ffffff;
font-family: "PixelOperatorMono", "Courier New", monospace;
}
.archive-node-name {
min-width: 0;
overflow-wrap: anywhere;
}
.archive-node-size {
color: var(--muted-foreground);
font-size: 0.78rem;
}
.archive-browser-empty {
margin: 0;
padding: 1rem;
color: var(--muted-foreground);
}
.archive-browser-legacy {
min-width: max-content;
margin: 0;
padding: 1rem;
color: var(--foreground);
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.55;
white-space: pre;
}
.preview-placeholder {
display: grid;
place-items: center;
gap: 0.8rem;
padding: 2rem;
color: var(--muted-foreground);
text-align: center;
}
.preview-placeholder[hidden],
.default-preview[hidden],
.native-preview[hidden],
.archive-browser-preview[hidden],
.large-preview-gate[hidden],
.code-preview[hidden],
.render-preview[hidden] {
display: none !important;
}
.preview-placeholder img {
width: 5rem;
height: 5rem;
object-fit: contain;
opacity: 0.78;
}
.preview-placeholder p {
margin: 0;
font-size: 0.9rem;
}
.code-preview {
min-width: 0;
width: 100%;
height: clamp(18rem, 64vh, 38rem);
overflow: auto;
background: #1b1724;
}
.code-preview pre[class*="language-"] {
width: max-content;
min-width: 100%;
min-height: 100%;
margin: 0;
border: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
font-size: 0.88rem;
line-height: 1.55;
overflow: visible;
text-shadow: none;
}
.code-preview pre {
width: max-content;
min-width: 100%;
min-height: 100%;
margin: 0;
padding: 1rem;
overflow: visible;
color: #f5f3ff;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.55;
white-space: pre;
}
.code-preview pre[class*="language-"] > code {
white-space: pre;
}
.code-preview code[class*="language-"] {
text-shadow: none;
}
.code-preview .token.punctuation {
opacity: 0.9;
}
.render-preview {
width: 100%;
height: clamp(18rem, 64vh, 38rem);
border: 0;
background: var(--background);
}
.preview-window:fullscreen,
.preview-window.is-render-fullscreen {
width: 100dvw;
height: 100dvh;
max-width: none;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
border: 0;
border-radius: 0;
background: var(--background);
}
.preview-window.is-render-fullscreen {
position: fixed;
inset: 0;
z-index: 1000;
}
.preview-window:fullscreen .preview-stage,
.preview-window.is-render-fullscreen .preview-stage {
min-height: 0;
height: 100%;
place-items: stretch;
}
.preview-window:fullscreen .render-preview,
.preview-window.is-render-fullscreen .render-preview {
width: 100%;
height: 100%;
}
.file-emblem { .file-emblem {
width: 4rem; width: 4rem;
height: 4rem; height: 4rem;
@@ -26,7 +678,54 @@
color: var(--muted-foreground); color: var(--muted-foreground);
} }
.file-emblem svg { .svg-icon {
width: 1rem;
height: 1rem;
display: inline-block;
flex: 0 0 auto;
background-color: currentColor;
vertical-align: -0.125em;
mask: var(--svg-icon-url) center / contain no-repeat;
-webkit-mask: var(--svg-icon-url) center / contain no-repeat;
}
.svg-icon-document {
--svg-icon-url: url("/static/icons/regular/submit-document.svg");
}
.svg-icon-share {
--svg-icon-url: url("/static/icons/regular/share-android.svg");
}
.svg-icon-download {
--svg-icon-url: url("/static/icons/regular/download.svg");
}
.svg-icon-list {
--svg-icon-url: url("/static/icons/regular/list.svg");
}
.svg-icon-grid {
--svg-icon-url: url("/static/icons/regular/view-grid.svg");
}
.svg-icon-emoji {
--svg-icon-url: url("/static/icons/regular/emoji.svg");
}
.svg-icon-open {
--svg-icon-url: url("/static/icons/regular/open-in-browser.svg");
}
.svg-icon-copy {
--svg-icon-url: url("/static/icons/regular/copy.svg");
}
.svg-icon-eye {
--svg-icon-url: url("/static/icons/regular/eye.svg");
}
.file-emblem .svg-icon {
width: 1.75rem; width: 1.75rem;
height: 1.75rem; height: 1.75rem;
} }
@@ -46,6 +745,17 @@
text-decoration: none; text-decoration: none;
} }
.button.is-disabled {
opacity: .62;
cursor: not-allowed;
pointer-events: none;
}
.download-share-button {
margin-top: 1rem;
margin-bottom: 0.65rem;
}
.upload-processing-alert { .upload-processing-alert {
margin: 1rem 0; margin: 1rem 0;
padding: .85rem 1rem; padding: .85rem 1rem;
@@ -55,6 +765,11 @@
color: var(--foreground); color: var(--foreground);
} }
.upload-processing-alert-error {
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
background: color-mix(in srgb, var(--danger) 14%, transparent);
}
.thumb-link { .thumb-link {
flex: 0 0 4.75rem; flex: 0 0 4.75rem;
width: 4.75rem; width: 4.75rem;
@@ -160,7 +875,7 @@
justify-content: center; justify-content: center;
} }
.view-toolbar svg { .view-toolbar .svg-icon {
width: 0.95rem; width: 0.95rem;
height: 0.95rem; height: 0.95rem;
} }
@@ -218,6 +933,24 @@
cursor: wait; cursor: wait;
} }
.file-card.is-failed {
border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
background: color-mix(in srgb, var(--danger) 8%, var(--background));
}
.file-card.is-failed .file-open {
cursor: not-allowed;
}
.file-error {
display: block;
max-width: 100%;
margin-top: 0.18rem;
color: var(--danger);
white-space: normal;
overflow-wrap: anywhere;
}
.file-reaction-dock { .file-reaction-dock {
position: static; position: static;
z-index: 2; z-index: 2;
@@ -311,14 +1044,9 @@
pointer-events: auto; pointer-events: auto;
} }
.reaction-button svg { .reaction-button .svg-icon {
width: 1.15rem; width: 1.15rem;
height: 1.15rem; height: 1.15rem;
fill: none;
stroke: currentColor;
stroke-width: 1.9;
stroke-linecap: round;
stroke-linejoin: round;
} }
.file-card:hover .reaction-button, .file-card:hover .reaction-button,
@@ -557,7 +1285,7 @@ html.reaction-picker-open body {
object-fit: contain; object-fit: contain;
} }
/* Retro (Win98) icons are tiny pixel art — keep them crisp and swap them in /* Retro (Win98) icons are tiny pixel art. Keep them crisp and swap them in
only when the retro theme is active. */ only when the retro theme is active. */
.file-icon-retro { .file-icon-retro {
display: none; display: none;
@@ -590,7 +1318,7 @@ html.reaction-picker-open body {
white-space: nowrap; white-space: nowrap;
} }
.file-type, .file type,
.file-size { .file-size {
overflow: hidden; overflow: hidden;
color: var(--muted-foreground); color: var(--muted-foreground);
@@ -673,7 +1401,7 @@ html.reaction-picker-open body {
padding-top: 0.25rem; padding-top: 0.25rem;
} }
.file-browser.is-thumbs .file-type, .file-browser.is-thumbs .file type,
.file-browser.is-thumbs .file-size { .file-browser.is-thumbs .file-size {
display: none; display: none;
} }
@@ -801,23 +1529,36 @@ html.reaction-picker-open body {
text-align: right; text-align: right;
} }
.preview-stage { @media (max-width: 720px) {
overflow: hidden; .preview-view {
margin-bottom: 1rem; width: min(100%, calc(100% - 1rem));
border: 1px solid var(--border); padding-block: 1rem;
border-radius: var(--radius);
background: var(--background);
} }
.preview-stage img, .preview-header {
.preview-stage video { flex-direction: column;
width: 100%; align-items: stretch;
max-height: 55vh;
display: block;
object-fit: contain;
} }
.preview-stage audio { .preview-header .button {
width: calc(100% - 2rem); justify-content: center;
margin: 1rem; }
.preview-window-titlebar > div:first-child {
display: grid;
gap: 0.2rem;
}
.preview-stage,
.code-preview,
.render-preview,
.native-preview {
min-height: 18rem;
height: min(60vh, 32rem);
}
.preview-stage > img,
.preview-stage > video {
max-height: min(60vh, 32rem);
}
} }

View File

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

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; grid-template-columns: 1fr;
} }
.api-docs {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.api-sidebar {
position: static;
top: auto;
}
.api-sidebar-title {
margin-bottom: 0.5rem;
}
.api-nav {
flex-direction: row;
flex-wrap: wrap;
border-left: 0;
padding-left: 0;
gap: 0.35rem;
}
.api-nav-link {
border: 1px solid var(--border);
}
.api-sidebar-meta {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.endpoint-list div em {
margin-left: 0;
width: 100%;
}
.app-sidebar { .app-sidebar {
position: static; position: static;
width: 100%; width: 100%;
@@ -122,7 +160,7 @@
grid-template-columns: 3rem minmax(0, 1fr) auto; grid-template-columns: 3rem minmax(0, 1fr) auto;
} }
.file-type { .file type {
display: none; display: none;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

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

@@ -6,8 +6,8 @@
* localStorage (no cookie, no server round-trip) and applies site-wide. * localStorage (no cookie, no server round-trip) and applies site-wide.
* *
* CSP note: this is an external /static file, so it is allowed under * CSP note: this is an external /static file, so it is allowed under
* script-src 'self'. We only toggle an attribute / class never inject inline * script-src 'self'. We only toggle an attribute / class and never inject inline
* <style> which keeps style-src 'self' happy. * <style>, which keeps style-src 'self' happy.
*/ */
(function () { (function () {
var STORAGE_KEY = "warpbox-theme"; var STORAGE_KEY = "warpbox-theme";

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",
});
}
}
}
})();

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
/* PrismJS 1.30.0
https://prismjs.com/download#themes=prism-dark&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig&plugins=line-numbers */
code[class*=language-],pre[class*=language-]{color:#fff;background:0 0;text-shadow:0 -.1em .2em #000;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}:not(pre)>code[class*=language-],pre[class*=language-]{background:#4c3f33}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border:.3em solid #7a6651;border-radius:.5em;box-shadow:1px 1px .5em #000 inset}:not(pre)>code[class*=language-]{padding:.15em .2em .05em;border-radius:.3em;border:.13em solid #7a6651;box-shadow:1px 1px .3em -.1em #000 inset;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#997f66}.token.punctuation{opacity:.7}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.number,.token.property,.token.symbol,.token.tag{color:#d1939e}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#bce051}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f4b73d}.token.atrule,.token.attr-value,.token.keyword{color:#d1939e}.token.important,.token.regex{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.deleted{color:red}
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,22 @@
"display": "standalone", "display": "standalone",
"background_color": "#0b0b16", "background_color": "#0b0b16",
"theme_color": "#8b5cf6", "theme_color": "#8b5cf6",
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "files",
"accept": ["*/*"]
}
]
}
},
"icons": [ "icons": [
{ {
"src": "/static/android-chrome-192x192.png", "src": "/static/android-chrome-192x192.png",

View File

@@ -4,23 +4,37 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} {{end}}{{.AppName}}</title> <title>{{if .Title}}{{.Title}} | {{end}}{{.AppName}}</title>
<meta name="description" content="{{.Description}}"> <meta name="description" content="{{.Description}}">
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}} {{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}"> <meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
<meta name="generator" content="Warp Box {{.AppVersion}}"> <meta name="generator" content="Warp Box {{.AppVersion}}">
<meta property="og:site_name" content="{{.AppName}}"> <meta property="og:site_name" content="{{.AppName}}">
<meta property="og:type" content="website"> <meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}"> <meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
<meta property="og:description" content="{{.Description}}"> <meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}"> <meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
{{if .ImageURL}} {{if .ImageURL}}
<meta property="og:image" content="{{.ImageURL}}"> <meta property="og:image" content="{{.ImageURL}}">
<meta property="og:image:secure_url" content="{{.ImageURL}}">
{{if .ImageType}}<meta property="og:image:type" content="{{.ImageType}}">{{end}}
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630"> <meta property="og:image:height" content="630">
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}} {{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
{{end}} {{end}}
{{if .MediaURL}}
{{if eq .OGType "video.other"}}
<meta property="og:video" content="{{.MediaURL}}">
<meta property="og:video:secure_url" content="{{.MediaURL}}">
{{if .MediaType}}<meta property="og:video:type" content="{{.MediaType}}">{{end}}
{{end}}
{{if eq .OGType "music.song"}}
<meta property="og:audio" content="{{.MediaURL}}">
<meta property="og:audio:secure_url" content="{{.MediaURL}}">
{{if .MediaType}}<meta property="og:audio:type" content="{{.MediaType}}">{{end}}
{{end}}
{{end}}
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}"> <meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
@@ -40,11 +54,13 @@
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script> <script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/04-dialogs.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/19-popups.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
@@ -53,13 +69,19 @@
<link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}"> <link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script> <script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/02-pwa.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/03-popups.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/04-dialogs.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script> <script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script> <script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/13-share.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script> <script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script> <script defer src="/static/js/25-admin-charts.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script> <script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script> <script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script> <script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/45-preview.js?version={{.AppVersion}}"></script>
<script defer src="/static/js/48-api-docs.js?version={{.AppVersion}}"></script>
</head> </head>
<body class="dark"> <body class="dark">
<a class="skip-link" href="#main">Skip to content</a> <a class="skip-link" href="#main">Skip to content</a>

View File

@@ -39,7 +39,7 @@
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label> <label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Update password</button> <button class="button button-primary" type="submit">Update password</button>
</form> </form>
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p> <p class="muted-copy">Public forgot password is deferred until SMTP support is added. Admins can generate reset links.</p>
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@
{{if .Data.NewToken}} {{if .Data.NewToken}}
<div class="token-reveal"> <div class="token-reveal">
<p class="token-reveal-title">Copy your new token now — it won't be shown again.</p> <p class="token-reveal-title">Copy your new token now. It won't be shown again.</p>
<div class="token-reveal-row"> <div class="token-reveal-row">
<code class="token-reveal-value" data-token-value>{{.Data.NewToken}}</code> <code class="token-reveal-value" data-token-value>{{.Data.NewToken}}</code>
<button class="button button-outline button-sm" type="button" data-token-copy>Copy</button> <button class="button button-outline button-sm" type="button" data-token-copy>Copy</button>

View File

@@ -106,7 +106,7 @@
</select> </select>
</label> </label>
</div> </div>
<p class="pagination-summary">Showing {{.Data.RangeFrom}}{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p> <p class="pagination-summary">Showing {{.Data.RangeFrom}}. {{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -113,7 +113,7 @@
</select> </select>
</label> </label>
</div> </div>
<p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p> <p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}. {{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -117,11 +117,11 @@
<input name="local_storage_max_gb" value="{{.Data.Settings.LocalStorageMaxGB}}" required> <input name="local_storage_max_gb" value="{{.Data.Settings.LocalStorageMaxGB}}" required>
</label> </label>
<label> <label>
<span>Short-window requests</span> <span>short window requests</span>
<input type="number" name="short_window_requests" min="1" value="{{.Data.Settings.ShortWindowRequests}}" required> <input type="number" name="short_window_requests" min="1" value="{{.Data.Settings.ShortWindowRequests}}" required>
</label> </label>
<label> <label>
<span>Short-window seconds</span> <span>short window seconds</span>
<input type="number" name="short_window_seconds" min="1" value="{{.Data.Settings.ShortWindowSeconds}}" required> <input type="number" name="short_window_seconds" min="1" value="{{.Data.Settings.ShortWindowSeconds}}" required>
</label> </label>
</div> </div>

View File

@@ -52,7 +52,7 @@
<div class="table-header"> <div class="table-header">
<div> <div>
<h2>Identity and limits</h2> <h2>Identity and limits</h2>
<p>Blank limit fields inherit the global user defaults. Use <code>-1</code> for unlimited in any limit field upload size, daily caps, storage quota, max expiration (the box can then last forever), daily boxes, active boxes, and short-window requests. Storage quota <code>0</code> also means unlimited.</p> <p>Blank limit fields inherit the global user defaults. Use <code>-1</code> for unlimited in any limit field, including upload size, daily caps, storage quota, max expiration (the box can then last forever), daily boxes, active boxes, and short window requests. Storage quota <code>0</code> also means unlimited.</p>
</div> </div>
</div> </div>
<form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post"> <form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post">
@@ -92,7 +92,7 @@
<label><span>Max expiration (days)</span><input type="number" min="-1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label> <label><span>Max expiration (days)</span><input type="number" min="-1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
<label><span>Daily boxes</span><input type="number" min="-1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label> <label><span>Daily boxes</span><input type="number" min="-1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
<label><span>Active boxes</span><input type="number" min="-1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label> <label><span>Active boxes</span><input type="number" min="-1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
<label><span>Short-window requests</span><input type="number" min="-1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label> <label><span>short window requests</span><input type="number" min="-1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
</div> </div>
<button class="button button-primary" type="submit">Save user</button> <button class="button button-primary" type="submit">Save user</button>

View File

@@ -1,68 +1,131 @@
{{define "api.html"}}{{template "base" .}}{{end}} {{define "api.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="docs-view" aria-labelledby="api-title"> <section class="api-docs" aria-labelledby="api-title" data-api-docs>
<div class="docs-header"> <aside class="api-sidebar">
<p class="kicker">Developer docs</p> <p class="kicker">Developer docs</p>
<h1 id="api-title">Warpbox API</h1> <h1 id="api-title" class="api-sidebar-title">Warpbox API</h1>
<p>Anonymous uploads for curl, scripts, and ShareX. The upload endpoint accepts multipart files and returns either plain text or JSON based on the <code>Accept</code> header.</p> <nav class="api-nav" aria-label="Documentation sections">
<a class="api-nav-link" href="#home" data-doc-link>Home</a>
<a class="api-nav-link" href="#endpoints" data-doc-link>Endpoints</a>
<a class="api-nav-link" href="#cli" data-doc-link>CLI / Binary</a>
<a class="api-nav-link" href="#integrations" data-doc-link>Integrations</a>
<a class="api-nav-link" href="#examples" data-doc-link>Examples</a>
<a class="api-nav-link" href="#faq" data-doc-link>FAQ</a>
</nav>
<div class="api-sidebar-meta">
<a href="{{.Data.RequestSchemaURL}}">Request schema</a>
<a href="{{.Data.ResponseSchemaURL}}">Response schema</a>
</div>
</aside>
<div class="api-content">
<!-- ===================== HOME ===================== -->
<section id="home" class="doc-panel" data-doc-panel="home" tabindex="-1">
<header class="panel-head">
<p class="kicker">Get started</p>
<h2>Upload files anywhere, from anything</h2>
<p class="lead">Warpbox is a one endpoint upload API. Send a multipart file with <code>curl</code>, a script, ShareX, or the <code>warpbox</code> CLI and get back a shareable box link. Request JSON to also receive private manage and delete URLs.</p>
</header>
<div class="shortcut-grid">
<a class="shortcut-card accent-blue" href="#examples" data-doc-link>
<span class="shortcut-eyebrow">60-second start</span>
<span class="shortcut-title">Copy-paste examples</span>
<span class="shortcut-sub">curl, wget, HTTPie, Python &amp; more</span>
</a>
<a class="shortcut-card accent-green" href="#cli" data-doc-link>
<span class="shortcut-eyebrow">Terminal</span>
<span class="shortcut-title">Install the CLI</span>
<span class="shortcut-sub">One command for macOS, Linux &amp; Windows</span>
</a>
<a class="shortcut-card accent-violet" href="#endpoints" data-doc-link>
<span class="shortcut-eyebrow">Reference</span>
<span class="shortcut-title">All endpoints</span>
<span class="shortcut-sub">Payloads, responses &amp; status codes</span>
</a>
<a class="shortcut-card accent-amber" href="#integrations" data-doc-link>
<span class="shortcut-eyebrow">Screenshots</span>
<span class="shortcut-title">ShareX integration</span>
<span class="shortcut-sub">Import once, upload as your account</span>
</a>
</div> </div>
<div class="docs-grid"> <div class="quickstart card">
<article class="card docs-card">
<div class="card-content"> <div class="card-content">
<h2>Endpoints</h2> <h3>Your first upload</h3>
<dl class="endpoint-list"> <p>No account required. This prints one plain box URL you can share immediately.</p>
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div> <figure class="code-block">
<div><dt>Resumable create</dt><dd><code>POST /api/v1/uploads/resumable</code></dd></div>
<div><dt>Resumable status</dt><dd><code>GET /api/v1/uploads/resumable/{sessionID}</code></dd></div>
<div><dt>Resumable chunk</dt><dd><code>PUT /api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}</code></dd></div>
<div><dt>Resumable complete</dt><dd><code>POST /api/v1/uploads/resumable/{sessionID}/complete</code></dd></div>
<div><dt>Health</dt><dd><code>GET /health</code></dd></div>
<div><dt>Request schema</dt><dd><a href="/api/v1/schemas/upload-request.json"><code>/api/v1/schemas/upload-request.json</code></a></dd></div>
<div><dt>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div>
</dl>
</div>
</article>
<article class="card docs-card docs-card-wide">
<div class="card-content">
<h2>Resumable uploads</h2>
<p>Browser uploads use the resumable API by default. Custom clients can use the same flow: create a session with file metadata, upload exact-sized chunks, then complete the session. Chunks are temporary and are cleaned if the session expires.</p>
<pre><code># 1. Create a session.
curl -s {{.Data.BaseURL}}/api/v1/uploads/resumable \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"files":[{"name":"report.pdf","size":1048576,"contentType":"application/pdf"}],"expiresMinutes":1440}'
# 2. Upload each chunk using the returned sessionId, file id, and chunkSize.
dd if=./report.pdf bs=8388608 count=1 skip=0 2>/dev/null | \
curl -X PUT --data-binary @- \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
# 3. Complete after all chunks are present. The response is the normal upload JSON.
curl -X POST -H 'Accept: application/json' \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/complete</code></pre>
<p class="muted-copy">For authenticated uploads, send the same <code>Authorization: Bearer &lt;token&gt;</code> header on every resumable request. Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
</div>
</article>
<article class="card docs-card">
<div class="card-content">
<h2>Curl upload</h2>
<p>Without a JSON <code>Accept</code> header, Warpbox prints one plain box URL for shell-friendly usage.</p>
<pre><code>curl -F file=@./report.pdf {{.Data.UploadURL}}</code></pre> <pre><code>curl -F file=@./report.pdf {{.Data.UploadURL}}</code></pre>
<p>For automation, request JSON to get file URLs and the private manage/delete URLs.</p> </figure>
<p class="muted-copy">Want file URLs, a manage link, and a delete link back? Add <code>-H 'Accept: application/json'</code>. See <a href="#responses" data-doc-link>the JSON response</a>.</p>
</div>
</div>
<h3 class="section-label">Quick links</h3>
<div class="link-grid">
<a class="link-pill" href="#ep-upload" data-doc-link><span class="link-tag tag-post">POST</span> Upload endpoint</a>
<a class="link-pill" href="/static/api/warpbox.sh" download><span class="link-tag tag-get">GET</span> warpbox.sh (macOS/Linux)</a>
<a class="link-pill" href="/static/api/warpbox.ps1" download><span class="link-tag tag-get">GET</span> warpbox.ps1 (Windows)</a>
<a class="link-pill" href="{{.Data.ShareXDownloadURL}}" download><span class="link-tag tag-get">GET</span> ShareX .sxcu config</a>
<a class="link-pill" href="{{.Data.RequestSchemaURL}}"><span class="link-tag tag-json">JSON</span> Request schema</a>
<a class="link-pill" href="{{.Data.ResponseSchemaURL}}"><span class="link-tag tag-json">JSON</span> Response schema</a>
<a class="link-pill" href="/account/settings"><span class="link-tag tag-key">KEY</span> Create an API token</a>
<a class="link-pill" href="#faq" data-doc-link><span class="link-tag tag-help">?</span> FAQ &amp; troubleshooting</a>
</div>
</section>
<!-- ===================== ENDPOINTS ===================== -->
<section id="endpoints" class="doc-panel" data-doc-panel="endpoints" tabindex="-1">
<header class="panel-head">
<p class="kicker">Reference</p>
<h2>Endpoints</h2>
<p class="lead">Base URL <code>{{.Data.BaseURL}}</code>. Authentication is optional: send <code>Authorization: Bearer &lt;token&gt;</code> to upload as your account and use your account limits, or omit it to upload anonymously.</p>
</header>
<article id="ep-upload" class="endpoint card">
<div class="card-content">
<div class="endpoint-head">
<span class="method method-post">POST</span>
<code class="endpoint-path">/api/v1/upload</code>
</div>
<p>The core endpoint. Accepts a <code>multipart/form-data</code> body with one or more files. Returns a plain box URL by default, or the full JSON object when you send <code>Accept: application/json</code>.</p>
<h4>Request fields</h4>
<div class="field-grid">
<span><code>file</code></span><p>One or more files. Repeat the field for multiple files. Used by curl, browsers, and the CLI.</p>
<span><code>sharex</code></span><p>Alternative file field used by ShareX custom uploader configs. Same behaviour as <code>file</code>.</p>
<span><code>max_days</code></span><p>Optional. Days before the box expires. Defaults to 7.</p>
<span><code>expires_minutes</code></span><p>Optional. Lifetime in minutes. Takes precedence over <code>max_days</code> when &gt; 0. Use it for expiries under a day (e.g. <code>60</code> = one hour).</p>
<span><code>max_downloads</code></span><p>Optional. Auto-expire the box after this many downloads.</p>
<span><code>password</code></span><p>Optional. Password required before viewing or downloading.</p>
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>. Hides file names/counts until unlock (only meaningful with a password).</p>
</div>
<h4>Request headers</h4>
<div class="field-grid">
<span><code>Accept</code></span><p><code>application/json</code> to receive the JSON body; otherwise a single plain-text URL.</p>
<span><code>Authorization</code></span><p>Optional <code>Bearer &lt;token&gt;</code>. Attributes the upload to your account.</p>
<span><code>X-Warpbox-Batch</code></span><p>Optional grouping key. Uploads sharing a value within {{.Data.ShareXGroupWindow}} land in the same box. See <a href="#integrations" data-doc-link>Integrations</a>.</p>
</div>
<h4>Example</h4>
<figure class="code-block">
<pre><code>curl -F file=@./report.pdf \ <pre><code>curl -F file=@./report.pdf \
-F max_downloads=5 \
-F expires_minutes=1440 \
-H 'Accept: application/json' \ -H 'Accept: application/json' \
{{.Data.UploadURL}}</code></pre> {{.Data.UploadURL}}</code></pre>
</figure>
</div> </div>
</article> </article>
<article class="card docs-card"> <article id="responses" class="endpoint card">
<div class="card-content"> <div class="card-content">
<h2>JSON response</h2> <h3>JSON response</h3>
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private. On error the body is <code>{ "error": "message" }</code> with a non-2xx status (e.g. rate limited or over a limit).</p> <p>Returned when <code>Accept: application/json</code> is sent. The raw delete token appears <strong>only once</strong>, inside <code>manageUrl</code> and <code>deleteUrl</code>, so store them privately. Full schema: <a href="{{.Data.ResponseSchemaURL}}">upload-response.json</a>.</p>
<figure class="code-block">
<pre><code>{ <pre><code>{
"boxId": "abc123", "boxId": "abc123",
"boxUrl": "{{.Data.BaseURL}}/d/abc123", "boxUrl": "{{.Data.BaseURL}}/d/abc123",
@@ -81,28 +144,181 @@ curl -X POST -H 'Accept: application/json' \
} }
] ]
}</code></pre> }</code></pre>
</figure>
<p class="muted-copy">On error the body is <code>{ "error": "message" }</code> with a non-2xx status. Common causes: <code>413</code> over the size limit, <code>429</code> rate limited or over your daily quota, <code>401</code> bad token.</p>
</div> </div>
</article> </article>
<article class="card docs-card"> <article id="ep-resumable" class="endpoint card">
<div class="card-content"> <div class="card-content">
<h2>ShareX setup</h2> <h3>Resumable uploads</h3>
<p>Import the uploader, then add your API key to upload as your account — with your account's size, daily, and retention limits — instead of as an anonymous guest.</p> <p>For large files. Browser uploads use this by default. Create a session with file metadata, <code>PUT</code> exact sized chunks, then complete. Chunks are temporary and cleaned if the session expires. Send the same <code>Authorization</code> header on every request for authenticated sessions.</p>
<div class="endpoint-list">
<div><span class="method method-post">POST</span><code>/api/v1/uploads/resumable</code><em>Create a session</em></div>
<div><span class="method method-get">GET</span><code>/api/v1/uploads/resumable/{sessionID}</code><em>Session status</em></div>
<div><span class="method method-put">PUT</span><code>/api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}</code><em>Upload one chunk</em></div>
<div><span class="method method-post">POST</span><code>/api/v1/uploads/resumable/{sessionID}/complete</code><em>Finalize (returns the upload JSON)</em></div>
<div><span class="method method-delete">DELETE</span><code>/api/v1/uploads/resumable/{sessionID}</code><em>Cancel and delete an unfinished session</em></div>
</div>
<figure class="code-block">
<pre><code># 1. Create a session.
curl -s {{.Data.BaseURL}}/api/v1/uploads/resumable \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"files":[{"name":"report.pdf","size":1048576,"contentType":"application/pdf"}],"expiresMinutes":1440}'
<h3>1 · Import the uploader</h3> # 2. Upload each chunk using the returned sessionId, file id, and chunkSize.
dd if=./report.pdf bs=8388608 count=1 skip=0 2>/dev/null | \
curl -X PUT --data-binary @- \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
# 3. Complete after all chunks are present. The response is the normal upload JSON.
curl -X POST -H 'Accept: application/json' \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/complete
# Optional: cancel an unfinished session.
curl -X DELETE -H 'X-Warpbox-Resume-Token: TOKEN' \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID</code></pre>
</figure>
<p class="muted-copy">Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
</div>
</article>
<article id="ep-meta" class="endpoint card">
<div class="card-content">
<h3>Health &amp; schemas</h3>
<div class="endpoint-list">
<div><span class="method method-get">GET</span><code>/health</code><em>Liveness check</em></div>
<div><span class="method method-get">GET</span><code>/api/v1/schemas/upload-request.json</code><em>Request JSON Schema</em></div>
<div><span class="method method-get">GET</span><code>/api/v1/schemas/upload-response.json</code><em>Response JSON Schema</em></div>
</div>
</div>
</article>
</section>
<!-- ===================== CLI / BINARY ===================== -->
<section id="cli" class="doc-panel" data-doc-panel="cli" tabindex="-1">
<header class="panel-head">
<p class="kicker">Terminal</p>
<h2>The <code>warpbox</code> CLI</h2>
<p class="lead">A tiny uploader script that wraps the API. It only needs <code>curl</code> (already on macOS, Linux, and Windows 10+). Point it at this instance once by setting <code>WARPBOX_HOST</code> to <code>{{.Data.BaseURL}}</code>, then upload from anywhere.</p>
</header>
<div class="download-row">
<div class="download-card">
<div class="download-os">macOS &amp; Linux</div>
<p>POSIX shell script (<code>warpbox.sh</code>).</p>
<a class="button button-primary" href="/static/api/warpbox.sh" download>Download for macOS / Linux</a>
</div>
<div class="download-card">
<div class="download-os">Windows</div>
<p>PowerShell script (<code>warpbox.ps1</code>).</p>
<a class="button button-primary" href="/static/api/warpbox.ps1" download>Download for Windows</a>
</div>
</div>
<article id="cli-install" class="card">
<div class="card-content">
<h3>Install &amp; add to PATH</h3>
<h4>macOS / Linux</h4>
<p>Download into a directory on your <code>PATH</code>, then make it executable. <code>~/.local/bin</code> is the recommended location.</p>
<figure class="code-block">
<pre><code>mkdir -p ~/.local/bin
curl -fsSL {{.Data.BaseURL}}/static/api/warpbox.sh -o ~/.local/bin/warpbox
chmod +x ~/.local/bin/warpbox
# Point it at this instance (add to ~/.profile or ~/.zshrc to keep it set)
echo 'export WARPBOX_HOST={{.Data.BaseURL}}' >> ~/.profile
# If 'warpbox: command not found', add the dir to PATH:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile
# zsh users: use ~/.zshrc, then reload with: source ~/.profile</code></pre>
</figure>
<p class="muted-copy">Verify with <code>warpbox --help</code>. Prefer a system wide install? Drop it in <code>/usr/local/bin</code> with <code>sudo</code>.</p>
<h4>Windows (PowerShell)</h4>
<p>Save the script, then add a function to your PowerShell profile so <code>warpbox</code> works anywhere.</p>
<figure class="code-block">
<pre><code># Save it to your home folder
iwr {{.Data.BaseURL}}/static/api/warpbox.ps1 -OutFile $HOME\warpbox.ps1
# Point it at this instance, and add a 'warpbox' command (run once)
setx WARPBOX_HOST "{{.Data.BaseURL}}"
Add-Content $PROFILE 'function warpbox { &amp; "$HOME\warpbox.ps1" @args }'
. $PROFILE # reload the profile</code></pre>
</figure>
<p class="muted-copy">If scripts are blocked, allow local scripts for your user: <code>Set-ExecutionPolicy -Scope CurrentUser RemoteSigned</code>.</p>
</div>
</article>
<article id="cli-usage" class="card">
<div class="card-content">
<h3>Usage</h3>
<p>A password, an expiry of two days, and a glob the shell expands for you:</p>
<figure class="code-block">
<pre><code>warpbox --password 123 --expiry 2d ./first_file.zip ./whatever.png ./all_*_photos.jpg</code></pre>
</figure>
<div class="field-grid">
<span><code>-p, --password</code></span><p>Require a password to open the box.</p>
<span><code>-e, --expiry</code></span><p>Lifetime: <code>30m</code>, <code>6h</code>, <code>2d</code>, <code>1w</code> (or bare minutes).</p>
<span><code>-n, --max downloads</code></span><p>Expire after N downloads.</p>
<span><code>-o, --obfuscate</code></span><p>Hide names/counts until unlock (needs <code>--password</code>).</p>
<span><code>--json</code></span><p>Print the full JSON response instead of just the URL.</p>
<span><code>--host</code></span><p>Server to upload to. Defaults to your <code>WARPBOX_HOST</code>.</p>
</div>
<p class="muted-copy">Windows uses PowerShell flags: <code>warpbox -Password 123 -Expiry 2d .\file.zip</code>.</p>
</div>
</article>
<article id="cli-auth" class="card">
<div class="card-content">
<h3>Secure authentication</h3>
<p>To upload as your account (and use your account's size, daily, and retention limits), the CLI needs an API token. <strong>Set it in your environment</strong> so it never appears in your shell history or in the process list that any user on the machine can read:</p>
<figure class="code-block">
<pre><code># macOS / Linux (add to ~/.profile or ~/.zshrc to persist)
export WARPBOX_TOKEN=wbx_your_token
warpbox ./photo.png
# Windows (persist for your user)
setx WARPBOX_TOKEN "wbx_your_token"</code></pre>
</figure>
<p>For CI or shared machines, keep the token in a file with locked down permissions and point the CLI at it. This avoids putting the secret on the command line at all:</p>
<figure class="code-block">
<pre><code>printf '%s' "wbx_your_token" > ~/.warpbox-token
chmod 600 ~/.warpbox-token
warpbox --auth-file ~/.warpbox-token ./photo.png</code></pre>
</figure>
<p class="muted-copy"><code>--auth &lt;token&gt;</code> exists for quick tests but is discouraged: it leaks into shell history and <code>ps</code>. Create or revoke tokens under <a href="/account/settings">Account, Access tokens</a>.</p>
</div>
</article>
</section>
<!-- ===================== INTEGRATIONS ===================== -->
<section id="integrations" class="doc-panel" data-doc-panel="integrations" tabindex="-1">
<header class="panel-head">
<p class="kicker">Integrations</p>
<h2>ShareX setup</h2>
<p class="lead">Import the uploader once, then optionally add your API key to upload as your account instead of as an anonymous guest.</p>
</header>
<article id="sharex" class="card">
<div class="card-content">
<h3>1. Import the uploader</h3>
<ol class="docs-steps"> <ol class="docs-steps">
<li>Download <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>warpbox-anonymous.sxcu</code></a>.</li> <li>Download <a href="{{.Data.ShareXDownloadURL}}" download><code>warpbox-anonymous.sxcu</code></a>.</li>
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li> <li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
</ol> </ol>
<h3>2 · Add your API key (upload as your account)</h3> <h3>2. Add your API key (optional, upload as your account)</h3>
<ol class="docs-steps"> <ol class="docs-steps">
<li>Create a personal access token under <a href="/account/settings">Account Access tokens</a> and copy it.</li> <li>Create a personal access token under <a href="/account/settings">Account, Access tokens</a> and copy it.</li>
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li> <li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
<li>Add a header Name <code>Authorization</code>, Value <code>Bearer &lt;your token&gt;</code>.</li> <li>Add a header. Name <code>Authorization</code>, Value <code>Bearer &lt;your token&gt;</code>.</li>
</ol> </ol>
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p> <p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
<figure class="code-block">
<pre><code>{ <pre><code>{
"Version": "1.0.0", "Version": "1.0.0",
"Name": "Warpbox (my account)", "Name": "Warpbox (my account)",
@@ -121,27 +337,187 @@ curl -X POST -H 'Accept: application/json' \
"DeletionURL": "{json:deleteUrl}", "DeletionURL": "{json:deleteUrl}",
"ErrorMessage": "{json:error}" "ErrorMessage": "{json:error}"
}</code></pre> }</code></pre>
</figure>
<h3>Grouping multiple files into one box</h3> <h3>Grouping multiple files into one box</h3>
<p>Grouping is <strong>opt-in via the <code>X-Warpbox-Batch</code> request header</strong> — without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a multi-file ShareX selection produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p> <p>Grouping is <strong>opt in via the <code>X-Warpbox-Batch</code> request header</strong>. Without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a ShareX selection of several files produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p> <p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
</div> </div>
</article> </article>
</section>
<article class="card docs-card docs-card-wide"> <!-- ===================== EXAMPLES ===================== -->
<section id="examples" class="doc-panel" data-doc-panel="examples" tabindex="-1">
<header class="panel-head">
<p class="kicker">Cookbook</p>
<h2>Examples</h2>
<p class="lead">Every snippet hits <code>POST {{.Data.UploadURL}}</code>. Add <code>-H 'Authorization: Bearer &lt;token&gt;'</code> to any of them to upload as your account.</p>
</header>
<article id="ex-curl" class="card">
<div class="card-content"> <div class="card-content">
<h2>Multipart fields</h2> <h3>curl</h3>
<div class="field-grid"> <p>Plain text (one URL) for the shell; JSON for automation.</p>
<span><code>file</code></span><p>One or more files for curl, browser, and generic multipart clients.</p> <figure class="code-block">
<span><code>sharex</code></span><p>One or more files from ShareX custom uploader configs.</p> <pre><code># Just the box URL
<span><code>max_days</code></span><p>Optional number of days before expiration. Defaults to 7.</p> curl -F file=@./report.pdf {{.Data.UploadURL}}
<span><code>expires_minutes</code></span><p>Optional lifetime in minutes. Takes precedence over <code>max_days</code> when greater than zero — useful for sub-day expiries (e.g. <code>60</code> for one hour).</p>
<span><code>max_downloads</code></span><p>Optional download count limit.</p> # Full JSON with manage + delete URLs, password and 1-hour expiry
<span><code>password</code></span><p>Optional password required before viewing/downloading.</p> curl -F file=@./report.pdf \
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>; hides names/counts until unlock when a password is set.</p> -F password=hunter2 \
</div> -F expires_minutes=60 \
-H 'Accept: application/json' \
{{.Data.UploadURL}}</code></pre>
</figure>
</div> </div>
</article> </article>
<article id="ex-wget" class="card">
<div class="card-content">
<h3>wget</h3>
<p>The endpoint needs a real <code>multipart/form-data</code> body, which <code>wget</code> can't assemble on its own, so build the body by hand and post it. It also shows the wire format:</p>
<figure class="code-block">
<pre><code>B=----warpbox$$
{ printf -- '--%s\r\nContent-Disposition: form-data; name="file"; filename="report.pdf"\r\nContent-Type: application/octet-stream\r\n\r\n' "$B"
cat ./report.pdf
printf -- '\r\n--%s--\r\n' "$B"; } > /tmp/wb.body
wget --quiet --output-document=- \
--header="Content-Type: multipart/form-data; boundary=$B" \
--header="Accept: application/json" \
--post-file=/tmp/wb.body \
{{.Data.UploadURL}}</code></pre>
</figure>
<p class="muted-copy">Add more form fields (<code>password</code>, <code>expires_minutes</code>, …) by repeating the <code>--%s … Content-Disposition: form-data; name="…"</code> block before the closing boundary. If this feels fiddly, <code>curl</code> or the CLI build the body for you.</p>
</div>
</article>
<article id="ex-httpie" class="card">
<div class="card-content">
<h3>HTTPie</h3>
<p>Multipart with form fields:</p>
<figure class="code-block">
<pre><code>http --multipart POST {{.Data.UploadURL}} \
Accept:application/json \
file@./report.pdf \
max_downloads=3 \
expires_minutes=1440</code></pre>
</figure>
</div>
</article>
<article id="ex-python" class="card">
<div class="card-content">
<h3>Python (requests)</h3>
<figure class="code-block">
<pre><code>import requests
with open("report.pdf", "rb") as f:
r = requests.post(
"{{.Data.UploadURL}}",
headers={"Accept": "application/json"}, # add "Authorization": "Bearer <token>"
files={"file": f},
data={"expires_minutes": 1440, "max_downloads": 5},
)
r.raise_for_status()
print(r.json()["boxUrl"])</code></pre>
</figure>
</div>
</article>
<article id="ex-node" class="card">
<div class="card-content">
<h3>Node.js (fetch)</h3>
<figure class="code-block">
<pre><code>import { readFile } from "node:fs/promises";
const form = new FormData();
form.set("file", new Blob([await readFile("report.pdf")]), "report.pdf");
form.set("expires_minutes", "1440");
const res = await fetch("{{.Data.UploadURL}}", {
method: "POST",
headers: { Accept: "application/json" }, // add Authorization: "Bearer <token>"
body: form,
});
const box = await res.json();
console.log(box.boxUrl);</code></pre>
</figure>
</div>
</article>
<article id="ex-ps" class="card">
<div class="card-content">
<h3>PowerShell</h3>
<p>PowerShell 7+ has native multipart with <code>-Form</code>:</p>
<figure class="code-block">
<pre><code>$resp = Invoke-RestMethod -Uri "{{.Data.UploadURL}}" -Method Post -Headers @{ Accept = "application/json" } -Form @{
file = Get-Item ".\report.pdf"
expires_minutes = 1440
}
$resp.boxUrl</code></pre>
</figure>
<p class="muted-copy">On Windows PowerShell 5.1, use the bundled <code>curl.exe</code> (the same approach the <a href="#cli" data-doc-link>CLI</a> takes) or the <code>warpbox.ps1</code> script.</p>
</div>
</article>
</section>
<!-- ===================== FAQ ===================== -->
<section id="faq" class="doc-panel" data-doc-panel="faq" tabindex="-1">
<header class="panel-head">
<p class="kicker">Help</p>
<h2>FAQ &amp; troubleshooting</h2>
<p class="lead">Quick answers, each linking back to the relevant part of the docs.</p>
</header>
<div class="faq-list">
<details class="faq-item">
<summary>Do I need an account or API key?</summary>
<p>No. Anonymous uploads work without one, see the <a href="#home" data-doc-link>quickstart</a>. Add a token only to upload as your account and use your account's limits; set one up under <a href="/account/settings">Account, Access tokens</a> and pass it as described in <a href="#cli-auth" data-doc-link>CLI authentication</a>.</p>
</details>
<details class="faq-item">
<summary>How do I send a password, expiry, or download limit?</summary>
<p>They're multipart form fields on the upload endpoint: <code>password</code>, <code>expires_minutes</code> (or <code>max_days</code>), and <code>max_downloads</code>. See the full list under <a href="#ep-upload" data-doc-link>Endpoints, request fields</a>, or use the CLI flags in <a href="#cli-usage" data-doc-link>CLI usage</a>.</p>
</details>
<details class="faq-item">
<summary>How do I get file URLs and a delete link back?</summary>
<p>Send <code>Accept: application/json</code>. The response includes <code>boxUrl</code>, per file <code>url</code>s, and the private <code>manageUrl</code>/<code>deleteUrl</code> (shown only once). See <a href="#responses" data-doc-link>the JSON response</a>.</p>
</details>
<details class="faq-item">
<summary>How do I upload one big file reliably?</summary>
<p>Use the <a href="#ep-resumable" data-doc-link>resumable endpoints</a>: create a session, PUT chunks, then complete. Interrupted uploads can resume from the last chunk, and unfinished sessions can be deleted with <code>DELETE /api/v1/uploads/resumable/{sessionID}</code>.</p>
</details>
<details class="faq-item">
<summary>Can clients show exact download progress?</summary>
<p>Yes. Individual file downloads and whole box <code>zipUrl</code> downloads include a precise <code>Content-Length</code>; range-capable responses also advertise <code>Accept-Ranges</code>.</p>
</details>
<details class="faq-item">
<summary>Can I upload several files into one shareable link?</summary>
<p>Yes. Send the <code>X-Warpbox-Batch</code> header with a shared value within {{.Data.ShareXGroupWindow}}. Details in <a href="#integrations" data-doc-link>Integrations, grouping</a>.</p>
</details>
<details class="faq-item">
<summary>Where's the keep-it-secret way to store my token?</summary>
<p>Use the <code>WARPBOX_TOKEN</code> environment variable or <code>--auth-file</code>, not <code>--auth</code> on the command line. Full guidance in <a href="#cli-auth" data-doc-link>CLI authentication</a>.</p>
</details>
<details class="faq-item">
<summary>My upload returns an error, what do the codes mean?</summary>
<p>Errors come back as <code>{ "error": "message" }</code> with a non-2xx status: <code>413</code> too large, <code>429</code> rate limited / over quota, <code>401</code> invalid token. See <a href="#responses" data-doc-link>error responses</a>.</p>
</details>
<details class="faq-item">
<summary>How do I use Warpbox from ShareX?</summary>
<p>Import the <code>.sxcu</code> and (optionally) add your token header. Step by step with the config in <a href="#integrations" data-doc-link>Integrations, ShareX setup</a>.</p>
</details>
<details class="faq-item">
<summary><code>warpbox: command not found</code> after install?</summary>
<p>The install directory isn't on your <code>PATH</code>. Fix it per your platform in <a href="#cli-install" data-doc-link>Install &amp; add to PATH</a>.</p>
</details>
<details class="faq-item">
<summary>Is there a machine-readable schema?</summary>
<p>Yes: <a href="{{.Data.RequestSchemaURL}}">upload-request.json</a> and <a href="{{.Data.ResponseSchemaURL}}">upload-response.json</a> (JSON Schema 2020-12).</p>
</details>
</div>
</section>
</div> </div>
</section> </section>
{{end}} {{end}}

View File

@@ -82,7 +82,7 @@
</details> </details>
</td> </td>
<td> <td>
<div>{{if .CollectionName}}{{.CollectionName}}{{else}}<span class="muted-copy"></span>{{end}}</div> <div>{{if .CollectionName}}{{.CollectionName}}{{else}}<span class="muted-copy">. </span>{{end}}</div>
<details class="row-edit"> <details class="row-edit">
<summary>Move</summary> <summary>Move</summary>
<form action="/app/boxes/{{.ID}}/move" method="post" class="row-edit-form"> <form action="/app/boxes/{{.ID}}/move" method="post" class="row-edit-form">

View File

@@ -5,7 +5,7 @@
<div class="card download-card"> <div class="card download-card">
<div class="card-content"> <div class="card-content">
<div class="file-emblem" aria-hidden="true"> <div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg> <span class="svg-icon svg-icon-document"></span>
</div> </div>
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1> <h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}} {{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
@@ -25,11 +25,17 @@
{{if .Data.Files}} {{if .Data.Files}}
{{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}} {{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}}
{{$failed := false}}{{range .Data.Files}}{{if .Failed}}{{$failed = true}}{{end}}{{end}}
{{if $processing}} {{if $processing}}
<div class="upload-processing-alert" role="status"> <div class="upload-processing-alert" role="status">
Some files are still processing. You can share this link now, but processing files will become available shortly. Some files are still processing. You can share this link now, but processing files will become available shortly.
</div> </div>
{{end}} {{end}}
{{if $failed}}
<div class="upload-processing-alert upload-processing-alert-error" role="alert">
Upload processing failed for one or more files. The original upload could not be finalized by the storage backend.
</div>
{{end}}
{{$single := eq (len .Data.Files) 1}} {{$single := eq (len .Data.Files) 1}}
<div class="badge-row"> <div class="badge-row">
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span> <span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
@@ -37,19 +43,29 @@
</div> </div>
{{if not .Data.Locked}} {{if not .Data.Locked}}
<button class="button button-outline button-wide download-share-button" type="button" data-share-box data-share-url="/d/{{.Data.Box.ID}}" data-share-title="{{if .Data.Locked}}Protected Warpbox box{{else}}Warpbox box {{.Data.Box.ID}}{{end}}" data-share-text="Shared files on Warpbox">
<span class="svg-icon svg-icon-share" aria-hidden="true"></span>
<span data-share-box-label>Share</span>
</button>
{{if or $processing $failed}}
<span class="button button-outline button-wide is-disabled" aria-disabled="true">
{{if $failed}}Download unavailable{{else}}Files processing{{end}}
</span>
{{else}}
{{if $single}} {{if $single}}
{{$first := index .Data.Files 0}} {{$first := index .Data.Files 0}}
<a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}"> <a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg> <span class="svg-icon svg-icon-download" aria-hidden="true"></span>
Download Download
</a> </a>
{{else}} {{else}}
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}"> <a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg> <span class="svg-icon svg-icon-download" aria-hidden="true"></span>
Download zip Download zip
</a> </a>
{{end}} {{end}}
{{end}} {{end}}
{{end}}
<div class="file-browser-window" data-file-browser-window> <div class="file-browser-window" data-file-browser-window>
<div class="file-browser-titlebar"> <div class="file-browser-titlebar">
@@ -64,11 +80,11 @@
<div class="file-browser-toolbar" aria-label="File view options"> <div class="file-browser-toolbar" aria-label="File view options">
<div class="view-toolbar"> <div class="view-toolbar">
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view"> <button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg> <span class="svg-icon svg-icon-list" aria-hidden="true"></span>
<span class="sr-only">List view</span> <span class="sr-only">List view</span>
</button> </button>
<button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view"> <button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg> <span class="svg-icon svg-icon-grid" aria-hidden="true"></span>
<span class="sr-only">Icon view</span> <span class="sr-only">Icon view</span>
</button> </button>
</div> </div>
@@ -80,8 +96,8 @@
</div> </div>
<div class="download-list file-browser is-thumbs" data-file-browser> <div class="download-list file-browser is-thumbs" data-file-browser>
{{range .Data.Files}} {{range .Data.Files}}
<article class="download-item file-card {{if .Processing}}is-processing{{end}}" data-kind="{{.PreviewKind}}" {{if not .Processing}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}"> <article class="download-item file-card {{if .Processing}}is-processing{{end}} {{if .Failed}}is-failed{{end}}" data-kind="{{.PreviewKind}}" {{if and (not .Processing) (not .Failed)}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}">
{{if .Processing}}<div class="file-open" aria-label="{{.Name}} is processing">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}} {{if or .Processing .Failed}}<div class="file-open" aria-label="{{.Name}} {{if .Failed}}failed processing{{else}}is processing{{end}}">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}}
<span class="file-media"> <span class="file-media">
{{if .HasThumbnail}} {{if .HasThumbnail}}
<img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy"> <img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy">
@@ -92,11 +108,12 @@
</span> </span>
<span class="file-main"> <span class="file-main">
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong> <strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
<small>{{.Size}} · {{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small> <small>{{.Size}} · {{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small>
{{if .Failed}}<small class="file-error">{{.Error}}</small>{{end}}
</span> </span>
<span class="file-type">{{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span> <span class="file type">{{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span>
<span class="file-size">{{.Size}}</span> <span class="file-size">{{.Size}}</span>
{{if .Processing}}</div>{{else}}</a>{{end}} {{if or .Processing .Failed}}</div>{{else}}</a>{{end}}
{{if not $.Data.Locked}} {{if not $.Data.Locked}}
<div class="file-reaction-dock" data-reaction-dock> <div class="file-reaction-dock" data-reaction-dock>
<div class="file-reactions" data-reaction-list> <div class="file-reactions" data-reaction-list>
@@ -112,7 +129,7 @@
</div> </div>
{{if not .Reacted}} {{if not .Reacted}}
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React"> <button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9Z" /><path d="M8 14s1.4 2 4 2 4-2 4-2" /><path d="M9 9h.01M15 9h.01" /></svg> <span class="svg-icon svg-icon-emoji" aria-hidden="true"></span>
</button> </button>
{{end}} {{end}}
</div> </div>
@@ -160,35 +177,35 @@
<small>File actions</small> <small>File actions</small>
<div class="context-menu-icons" aria-label="Quick actions"> <div class="context-menu-icons" aria-label="Quick actions">
<button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview"> <button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg> <span class="svg-icon svg-icon-open" aria-hidden="true"></span>
</button> </button>
<button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL"> <button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg> <span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
<span data-context-label class="sr-only">Copy</span> <span data-context-label class="sr-only">Copy</span>
</button> </button>
</div> </div>
</div> </div>
<hr> <hr>
<button type="button" role="menuitem" data-context-action="preview"> <button type="button" role="menuitem" data-context-action="preview">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" /><circle cx="12" cy="12" r="3" /></svg> <span class="svg-icon svg-icon-eye" aria-hidden="true"></span>
<span data-context-label>Preview</span> <span data-context-label>Preview</span>
</button> </button>
<button type="button" role="menuitem" data-context-action="view"> <button type="button" role="menuitem" data-context-action="view">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg> <span class="svg-icon svg-icon-open" aria-hidden="true"></span>
<span data-context-label>View raw file</span> <span data-context-label>View raw file</span>
</button> </button>
<hr> <hr>
<button type="button" role="menuitem" data-context-action="copy-preview"> <button type="button" role="menuitem" data-context-action="copy-preview">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg> <span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
<span data-context-label>Copy Preview</span> <span data-context-label>Copy Preview</span>
</button> </button>
<button type="button" role="menuitem" data-context-action="copy-download"> <button type="button" role="menuitem" data-context-action="copy-download">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg> <span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
<span data-context-label>Copy Download</span> <span data-context-label>Copy Download</span>
</button> </button>
<hr> <hr>
<button type="button" role="menuitem" data-context-action="download"> <button type="button" role="menuitem" data-context-action="download">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg> <span class="svg-icon svg-icon-download" aria-hidden="true"></span>
<span data-context-label>Download</span> <span data-context-label>Download</span>
</button> </button>
</div> </div>

View File

@@ -10,7 +10,7 @@
{{end}} {{end}}
</div> </div>
<form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data"> <form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data" data-max-upload-bytes="{{.Data.MaxUploadBytes}}" data-max-upload-label="{{.Data.MaxUploadSize}}">
<div class="card upload-main"> <div class="card upload-main">
<div class="card-content"> <div class="card-content">
{{if .CurrentUser}} {{if .CurrentUser}}
@@ -25,7 +25,7 @@
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg> <svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg>
</span> </span>
<span class="drop-title">Drop files to upload</span> <span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span> <span class="drop-copy">or click to browse, paste files, or drop a folder</span>
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span> <span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
<input id="file-input" name="file" type="file" multiple> <input id="file-input" name="file" type="file" multiple>
</label> </label>
@@ -76,8 +76,14 @@
<div class="form-footer"> <div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p> <p id="file-summary">Choose one or more files to begin.</p>
<button class="button button-primary" type="submit">Upload files</button> <button class="button button-outline install-pwa-button upload-idle-action" type="button" data-install-pwa hidden>Install Warpbox</button>
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button> <button class="button button-outline folder-picker-button upload-idle-action" type="button" data-folder-picker hidden>Choose folder</button>
<button class="button button-primary upload-idle-action" type="submit">Upload files</button>
<button class="button button-danger upload-new-button upload-idle-action" type="button" id="new-upload" hidden>New upload</button>
<div class="upload-active-actions" id="upload-active-actions" hidden>
<button class="button button-danger" type="button" id="cancel-upload">Cancel Upload</button>
<button class="button button-outline" type="button" id="pause-upload">Pause Upload</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,8 @@
{{define "preview.html"}}{{template "base" .}}{{end}} {{define "preview.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="download-view" aria-labelledby="preview-title"> <section class="download-view preview-view" aria-labelledby="preview-title">
<div class="card download-card"> <div class="card download-card preview-card">
<div class="card-content"> <div class="card-content">
{{if .Data.Locked}} {{if .Data.Locked}}
<div class="file-emblem" aria-hidden="true"> <div class="file-emblem" aria-hidden="true">
@@ -12,23 +12,70 @@
<p class="download-subtitle">Unlock the box before viewing this file.</p> <p class="download-subtitle">Unlock the box before viewing this file.</p>
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a> <a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
{{else}} {{else}}
<div class="preview-stage"> <header class="preview-header">
{{if eq .Data.File.PreviewKind "image"}} <div class="preview-title-group">
<img src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}">
{{else if eq .Data.File.PreviewKind "video"}}
<video src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata"></video>
{{else if eq .Data.File.PreviewKind "audio"}}
<audio src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata"></audio>
{{else}}
<img src="{{.Data.File.ThumbnailURL}}" alt="">
{{end}}
</div>
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1> <h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p> <p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
<a class="button button-primary button-wide" href="{{.Data.DownloadURL}}"> </div>
<a class="button button-primary" href="{{.Data.DownloadURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg> <svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download file Download
</a> </a>
</header>
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}" data-archive-url="{{.Data.File.ArchiveURL}}">
<div class="preview-window-titlebar">
<div>
<strong data-preview-mode-label>Preview</strong>
<span>{{.Data.File.ContentType}}</span>
</div>
<div class="preview-window-tools">
<button class="preview-fullscreen-button" type="button" data-render-fullscreen hidden>Full Screen</button>
<div class="preview-window-actions" aria-hidden="true"><span></span><span></span><span></span></div>
</div>
</div>
<div class="preview-tabs" data-preview-tabs></div>
<div class="preview-stage">
<div class="default-preview" data-default-preview hidden>
<img src="{{.Data.File.IconURL}}" alt="" loading="lazy">
<div>
<strong title="{{.Data.File.Name}}">{{.Data.File.Name}}</strong>
<span>{{.Data.File.Size}} · {{.Data.File.ContentType}}</span>
</div>
<a class="button button-primary" href="{{.Data.DownloadURL}}">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
Download
</a>
</div>
<img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden>
<video class="native-preview native-video-preview" data-video-preview src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata" hidden></video>
{{if .Data.File.HasScene}}<img class="native-preview video-scenes-preview" data-video-scenes-preview data-scene-src="{{.Data.File.SceneURL}}" alt="Scenes preview for {{.Data.File.Name}}" hidden>{{end}}
<audio class="native-preview native-audio-preview" data-browser-audio-preview src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata" hidden></audio>
<div class="code-preview raw-code-preview" data-raw-preview hidden>
<pre><code data-raw-output></code></pre>
</div>
<div class="code-preview prism-code-preview" data-code-preview hidden>
<pre class="line-numbers"><code data-code-output></code></pre>
</div>
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
<div class="archive-preview code-preview" data-archive-preview hidden>
<pre><code data-archive-output></code></pre>
</div>{{end}}
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
<div class="large-preview-gate" data-large-preview-gate hidden>
<strong>Large preview</strong>
<p>This file is larger than 500 KB. Loading this preview may be slow on some devices.</p>
<div>
<button class="button button-primary" type="button" data-large-preview-confirm>Load anyway</button>
<button class="button button-outline" type="button" data-large-preview-cancel>Cancel</button>
</div>
</div>
<div class="preview-placeholder" data-preview-placeholder hidden>
<img src="{{.Data.File.IconURL}}" alt="">
<p>Preparing preview...</p>
</div>
</div>
</div>
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@@ -1,713 +0,0 @@
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
# Ok, where's the .md document for me to look at? I need a comprehensive, deep look into the UI/UX style/design. Write it for me
Heres the full markdown document for the Warpbox.dev UI/UX design and style guide. You can copy this into a `.md` file asis.
***
# Warpbox.dev UI/UX Design \& Style Guide
This document defines the user interface (UI) and user experience (UX) guidelines for Warpbox.dev across all stages of the product (anonymous uploads, accounts, admin, embeds, ShareX, scaling, theming). It is intended for designers and developers implementing the web frontend and any graphical configuration or integration tools.
Warpbox.dev should feel:
- Minimal and modern.
- Fast and responsive.
- Obvious for firsttime users.
- Powerful and efficient for advanced users.
Modern selfhosted file sharing tools like Cloudreve, Zipline, Erugo, and similar projects are praised for a clean, streamlined UI, draganddrop uploads, and predictable dashboards; Warpbox.devs design should follow those expectations.[^1][^2][^3]
***
## 1. Global UX Principles
1. **Simplicity over feature density**
- Each screen has one primary purpose (e.g., upload, browse, administer) with one clearly dominant action.
- Secondary actions are available but visually subdued.
2. **Progress and feedback everywhere**
- Any longrunning operation (uploads, deletions, archive generation, thumbnailing) displays visible progress and completion status.
- Use inline progress bars and toast notifications for success/failure.
3. **Predictable navigation and structure**
- Users should always know:
- Where they are (anonymous view, user dashboard, admin area).
- How to go “home”.
- How to sign in/out.
- Navigation labels and locations must remain consistent across pages.
4. **Mobilefirst and responsive**
- All core workflows (upload, view, browse, share, basic admin tasks) must work comfortably on mobile phones.
- Layouts degrade gracefully to singlecolumn views; tables become lists/cards.
5. **Accessible by default**
- Keyboard navigable: all interactive elements reachable via Tab/Shift+Tab.
- Visible focus states; do not remove the outline.
- Color contrast meets WCAG AA for text and interactive elements.
- Use semantic HTML and ARIA roles for complex components (dialogs, nav, tabs).
6. **Configurable branding, consistent structure**
- Colors, logos, and text copy can be themed per instance, but:
- Layout patterns (where nav is, how tables look) stay consistent.
- Core component behavior (upload component, file lists, drawers, dialogs) does not change between instances.
7. **Low cognitive load for firsttime users**
- Use plain, descriptive labels (“Upload files”, “Copy link”, “New folder”) rather than jargon.
- Keep advanced options behind a clearly labeled “Advanced options” section.
***
## 2. Global Layout, Navigation, and Visual Language
### 2.1 Application Shell
Warpbox.dev uses a common shell across authenticated views, with a simplified variant for anonymous users.
**Header (top bar):**
- Left:
- Brand logo (or wordmark).
- Instance name (e.g., “warpbox.dev” or custom brand).
- Center:
- Contextual page title (“Upload files”, “My files”, “Admin dashboard”).
- Right:
- Primary navigation and user menu:
- Anonymous: “About”, “Docs”, “Login”, “Register”.
- Authenticated: icons + labels for Notifications (optional) and a user avatar / initial that opens a dropdown (“Profile”, “Settings”, “Logout”).
**Content area:**
- Fixed max width (e.g., 12001280px) centered on large screens, full width on mobile.
- Main content column with optional left sidebar:
- User dashboard/admin: sidebar for navigation + main content.
- Anonymous landing: no sidebar; main content centered and focused on upload.
**Footer:**
- Instance details (version, host).
- Links: “Terms of Service”, “Privacy Policy”, “Contact”.
- Optional small tagline or “Powered by Warpbox.dev”.
### 2.2 Navigation Patterns
**Anonymous users:**
- Top navigation only:
- Logo → landing/upload page.
- “About” → short description page.
- “Docs” → documentation site.
- “Login” / “Register” → auth screens.
**Authenticated users (regular):**
- Left sidebar (desktop) or slideout drawer (mobile) containing:
- Dashboard (default after login).
- My files.
- Shared links (optional, later stage).
- Settings.
- “Admin” (only visible if user has admin role).
**Admin users:**
- Within Admin area, the sidebar includes:
- Overview.
- Files.
- Users.
- Storage.
- Settings.
- Logs.
Use icons plus text (“Files”, “Users”) rather than icons alone.
### 2.3 Visual Language
**Typography:**
- Use a single, clean sansserif stack:
- Example: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`.
- Base font size: 16px; small text no smaller than 1314px.
- Headings:
- Page titles: 2432px.
- Section headings: 1822px.
**Color system:**
- Primary color: used for:
- Primary buttons.
- Selected navigation items.
- Progress bars.
- Neutral greys:
- Light background (\#f5f5f5\#fafafa).
- Cards (\#ffffff) with subtle shadows or borders.
- Dividers and table borders (\#e5e5e5\#dddddd).
- Semantic colors:
- Success (green) for completed uploads or successful actions.
- Warning (amber) for impending expiration, quota usage.
- Error (red) for failures and destructive actions.
**Buttons and controls:**
- Primary button: filled, uses primary color.
- Secondary button: outline or ghost style.
- Destructive button: red filled or outlined, clearly distinct.
- Inputs:
- Label above the field.
- Helper text and inline error message below.
**Components:**
- Cards: used for metrics, quick links, file summaries.
- Tables: used for lists of files/users/logs on desktop.
- List or card layouts: used for files on mobile.
***
## 3. PerStage UI/UX Specification
This section ties UI/UX decisions directly to each implementation stage.
### 3.1 Stage 1 Minimal Anonymous Upload MVP
**Goal:** A firsttime visitor should be able to upload and share a file in seconds without an account.
#### 3.1.1 Landing / Upload Page
**Layout:**
- Fullheight viewport with content vertically centered.
- A single, prominent upload card:
- Large drop zone:
- Icon (e.g., cloud with arrow).
- Primary text: “Drop files to upload”.
- Secondary text: “or click to browse”.
- Dashed border and subtle hover state.
- Below the drop zone:
- “Upload limits” micropanel:
- “Max file size: X GB”.
- “Link expires after Y days”.
- This panel uses muted colors to avoid competing with the drop zone.
**Behavior:**
- Dragover feedback:
- Entire drop zone highlights with primary color border.
- Text may change to “Release to upload”.
- On file selection:
- Show a list (vertical) of pending files:
- Name.
- Size.
- Progress bar.
- As each file finishes:
- Replace progress bar with:
- Direct link field (readonly text).
- “Copy link” button (obvious icon and label).
- Empty state:
- When no uploads: display only drop zone and small explanatory text.
**Error and feedback:**
- If file exceeds max size:
- Inline error row under that file: “This file exceeds the maximum allowed size.”
- If network error:
- Toast notification: “Upload failed, please try again.” with retry button for the file if possible.
Consistency with tools like Erugo and Zipline (prominent draganddrop upload, simple layout, clear share link) is intentional, as these interfaces tend to receive positive feedback for usability.[^2][^3]
***
### 3.2 Stage 2 Robust Anonymous Mode and Housekeeping
**Goal:** Anonymous upload mode becomes productionworthy, with advanced options and basic operational views.
#### 3.2.1 Enhanced Upload Options
**Add an “Advanced options” panel below the drop zone:**
- Collapsed by default; label: “Advanced options”.
- When expanded, show:
- Expiration:
- Label: “Expires in”.
- Dropdown: “1 day”, “7 days”, “30 days”, “Custom”.
- Max downloads:
- Numeric input (optional).
- Helper text: “Leave empty for unlimited downloads until expiration.”
- Password:
- Password field with “Show” toggle.
- Helper text: “Recipients must enter this password to download.”
These options reflect highly requested features in selfhosted tools (expiry, password, download limit).[^4]
#### 3.2.2 PostUpload Summary
After successful uploads:
- Display a success card at the top:
- Title: “Upload complete”.
- Summary:
- “3 files uploaded.”
- “Expires in 7 days, max 10 downloads per link.”
- Action buttons:
- “Copy all links”.
- “Upload more” (secondary).
Below, show individual file rows with:
- File name and size.
- Direct link.
- Buttons: “Copy link”, “Open”.
#### 3.2.3 Basic Anonymous Metrics (Operator View)
Introduce a very simple admin view (even before full admin stage) accessible only to admin users:
- Metrics cards:
- Total files.
- Total storage used.
- Uploads in last 24h.
- Recent uploads table:
- Columns: Time, File/Bucket ID, Size, IP (masked), Status.
- Actions: “View”, “Delete”.
This mirrors common UX patterns in dashboards for tools like Cloudreve and Zipline (metric cards + table of recent activity).[^3][^1]
***
### 3.3 Stage 3 User Accounts and Simple Folders
**Goal:** Provide a persistent personal space with minimal friction.
#### 3.3.1 Authentication Screens
**Login / Register pages:**
- Centered card layout against a simple background.
- Elements:
- Logo + instance name.
- Title: “Sign in” or “Create account”.
- Fields:
- Email.
- Password (+ confirm for registration).
- Buttons:
- Primary action.
- Link to switch between login/register.
- Link to go back to home/upload page.
Validation errors appear inline below fields, not in alerts.
#### 3.3.2 User Dashboard (Flat Collections)
After login, route to “Dashboard”:
**Layout:**
- Sidebar:
- “Dashboard”.
- “My files”.
- “Settings”.
- “Admin” (if role).
- Main content:
- Top row:
- Page title: “My files”.
- Primary button: “Upload” (opens upload overlay or reuses drop zone at top of page).
- File list table:
- Columns: Name, Collection, Size, Uploaded, Actions.
- Row actions: “View”, “Rename”, “Move (collection)”, “Delete”.
**Empty state:**
- When user has no files:
- Illustration or icon.
- Text: “You have no files yet.”
- Button: “Upload your first file”.
The goal is a lightweight “personal file library” similar to lightweight alternatives to Nextcloud that people often praise for being less cluttered.[^1]
***
### 3.4 Stage 4 Full Folder Hierarchy and File Metadata
**Goal:** Upgrade from flat collections to a robust folder tree and search.
#### 3.4.1 Folder Tree and Breadcrumbs
**Desktop layout:**
- Left column (min-width ~240px):
- “My files” root node.
- Expandable tree of nested folders (chevrons for expand/collapse).
- Main area:
- Breadcrumb at top: `My files / Projects / Client A`.
- Toolbar:
- “New folder”.
- “Upload”.
- “Share” (for current folder).
- Content:
- First row: folders (as cards or rows).
- Then: files.
**Mobile layout:**
- Folder tree becomes:
- A “Folders” button in the header, opening a fullscreen drawer with the tree.
- Breadcrumb appears as a single line above the list, truncated with ellipsis if long.
#### 3.4.2 File Cards/Table
Support both table and card view:
- Table view (default on desktop):
- Columns: Name, Type, Size, Modified, Visibility.
- Card view (toggle):
- Each card shows thumbnail/icon, name, size, and quick actions.
Search bar sits above the list, with placeholder: “Search files and folders”.
***
### 3.5 Stage 5 Admin Panel and Moderation Tools
**Goal:** Allow admins to manage content and users efficiently, with clear risk indicators.
#### 3.5.1 Admin Overview Dashboard
**Layout:**
- Metric cards at top:
- Total files.
- Total storage.
- Uploads today.
- Downloads today.
- Middle section:
- Simple charts (sparkline or bar chart for uploads/downloads over last 730 days).
- Bottom:
- Two tables sidebyside (or stacked on mobile):
- “Recent uploads” (last N).
- “Recent flags” (content flagged for TOS or abuse).
Use a neutral background and accent charts with the primary color, following patterns from popular file management dashboards.[^5]
#### 3.5.2 Files and Users Admin Tables
**Files page:**
- Filters panel:
- Date range.
- Status (Active, Expired, Flagged).
- Owner type (Anonymous, Authenticated).
- Size range (optional).
- Table columns:
- ID / Short ID.
- Name (or bucket label).
- Owner.
- Size.
- Uploaded.
- Flags.
- Actions.
- Actions:
- “View details” (opens side drawer with metadata, logs, and links).
- “Delete”.
- “Change expiration”.
- “Flag / Clear flag”.
**Users page:**
- Columns:
- Email.
- Role.
- Total storage used.
- Last active.
- Status (Active, Locked).
- Actions:
- “View user files” (filters Files page by user).
- “Lock/Unlock account”.
- “Change role”.
Destructive actions use red buttons and confirmation dialogs summarizing the consequences.
***
### 3.6 Stage 6 Embeds, Public Views, and SEO
**Goal:** Make shared links visually appealing and galleries easy to browse.
#### 3.6.1 File Landing Pages
**Layout:**
- Center column:
- Top:
- Thumbnail or icon (image / video / generic).
- File name.
- Size.
- Primary action row:
- “Download” (primary button).
- “Copy link” (secondary).
- “Open in browser” (for compatible types).
- Metadata panel:
- Expires: `YYYY-MM-DD`.
- Downloads: `N`.
- Owner (optional).
- For file owners, additional inline controls:
- “Extend expiration”.
- “Disable link”.
- “Set password” (if supported).
This aligns with expectations for simple, clean landing pages similar to what users see in Erugo and comparable services that highlight a large primary action.[^2]
#### 3.6.2 Public Galleries
**Grid layout:**
- Cards per file:
- Thumbnail or icon.
- Name (truncated with ellipsis).
- Size.
- Quick actions on hover/tap: “View”, “Download”.
**Gallery header:**
- Title: folder name.
- Description: optional text from owner.
- Filters and sort:
- Type (All, Images, Videos, Documents).
- Sort by (Newest, Oldest, Name, Size).
On mobile, cards stack and filters become a dropdown or horizontal scrollable chip row.
***
### 3.7 Stage 7 ShareX, CLI, and Desktop Integration
**Goal:** Make configuration and use of integrations effortless.
#### 3.7.1 Integrations / API Settings
Under Settings → Integrations/API:
**Sections:**
1. **API Tokens:**
- Table:
- Name.
- Scope (read/write).
- Created at.
- Last used.
- Buttons:
- “Create new token” (opens dialog with name + scope).
- “Revoke” per token.
2. **ShareX:**
- Description: one short paragraph explaining what ShareX is and how it works.
- Buttons:
- “Download ShareX config (.sxcu)” for anonymous upload.
- “Download ShareX config (.sxcu)” for authenticated upload.
- Below: simple numbered instructions, mirroring how other ShareX servers guide users (“Open ShareX → Destinations → Custom uploader → Import .sxcu”).[^6][^7]
3. **CLI:**
- Code block showing example:
```bash
warpbox upload ./file.ext \
--server https://warpbox.example \
--token <your-token>
```
- “Copy command” button.
***
### 3.8 Stage 8 Storage Backends, Scaling, and Background Workers
**Goal:** Make operational complexity understandable through clear UI.
#### 3.8.1 Storage Management Screen
In Admin → Storage:
- List of backends:
- Name.
- Type (Local, S3, etc.).
- Default (Yes/No).
- Status indicator (green/orange/red).
- Actions:
- “Add backend”.
- Per backend: “Edit”, “Test connection”, “Set as default”.
**Backend configuration form:**
- Fields:
- Type dropdown.
- For S3: endpoint, bucket, region, access key, secret key, prefix.
- For local: base path.
- Test button:
- On click, shows inline result (“Connection successful”, “Bucket not found”).
This mirrors configuration UX found in Cloudreve and similar multibackend tools.[^8][^9]
#### 3.8.2 Background Jobs View
Admin → Jobs / Tasks:
- Cards summarizing:
- Cleanup: last run time, status.
- Thumbnails: queue length, last error.
- Stats aggregation: last run.
- Table of recent job executions:
- Time.
- Task type.
- Status.
- Duration.
- Error message (if any).
***
### 3.9 Stage 9 Theming, i18n, and QoL Extras
**Goal:** Allow instances to adopt their own identity and refine UX.
#### 3.9.1 Branding and Themes
Admin → Branding:
- Fields:
- Logo upload (light/dark variants optional).
- Favicon upload.
- Primary color picker.
- Secondary/Accent color picker.
- Live preview panel:
- Shows sample header, button, and card using chosen colors.
Users often request dark mode and custom branding in selfhosted tools; Zipline, Erugo, and Cloudreve promote dark themes and custom branding as key features.[^10][^3][^2]
#### 3.9.2 Languages and Locale
Settings → Language:
- Userlevel dropdown for language selection.
- Admin setting for default instance language.
All text strings in the UI should be sourced from translation files, allowing community contributions.
#### 3.9.3 QualityofLife UI
- **QR codes on file landing pages:**
- Small QR icon next to link.
- Clicking opens overlay with QR code and “Download link” caption.
- **Text paste mode:**
- On upload page, tabs: “Files” and “Text”.
- “Text” tab shows large textarea and “Create paste” button.
- Result: landing page with monospace view and copy buttons.
- **Email notifications:**
- Perfile toggle in file details: “Notify me on first download”.
- Summary of notification status in file table (icon).
***
## 4. Interaction Patterns and States
### 4.1 Loading and Empty States
- Use skeleton loaders or subtle spinners for:
- Initial dashboard.
- Admin overview.
- Large folder loads.
- Empty states:
- Should include:
- Short explanation of what the page will show.
- A primary action (e.g., “Upload file”, “Create folder”).
### 4.2 Errors and Validation
- Local validation (like required fields) should be indicated:
- Red border around field.
- Short error text below.
- Server errors:
- Show as banner at top of content area:
- Example: “Unable to save changes. Please try again.”
- Critical destructive actions:
- Use confirmation dialogs that:
- Name the item.
- Ask explicit confirmation (input of name optional for irreversible actions).
### 4.3 Notifications and Toasts
- Use small toast notifications for transient events:
- Upload completed.
- Link copied.
- Settings saved.
- Behavior:
- Appear in a consistent corner (e.g., bottom right).
- Autodismiss after a few seconds.
- Pause dismissal when hovered.
***
## 5. Accessibility and Responsiveness
### 5.1 Accessibility
- Ensure all interactive elements:
- Have accessible names (labels, arialabels).
- Are reachable via keyboard.
- Modals:
- Trap focus within the dialog.
- Close on Esc.
- Screen readers:
- Announce page titles on navigation.
- Announce toast messages if they convey critical information.
### 5.2 Responsive Behavior
- Breakpoints:
- Mobile: < 640px.
- Tablet: 6401024px.
- Desktop: > 1024px.
- On smaller screens:
- Sidebars collapse to menu buttons.
- Tables may become stacked cards:
- Each card shows the most important fields (name, size, key actions).
***
## 6. Consistency Rules
1. **One primary action per view**
- Only one button per page uses the primary color and full button style.
- All others are secondary or tertiary.
2. **Shared components across modes**
- The upload component used for anonymous users is reused (with minor variations) for authenticated and admin contexts.
3. **Uniform terminology**
- Use specific terms consistently:
- “Upload”, not “Send”.
- “Folder”, not “Directory”.
- “File link”, not “URL” in userfacing text.
4. **Stable icon language**
- Use one icon set (line or filled) throughout.
- Same icon always means the same action (trash for delete, pencil for edit).
***
This document is meant to be your comprehensive UI/UX specification. You can paste it directly into `warpbox-ui-ux-style-guide.md` in your repo and evolve it alongside your implementation stages.
<div align="center">⁂</div>
[^1]: https://www.reddit.com/r/selfhosted/comments/1oo826y/testing_cloudreve_as_a_lightweight_nextcloud/
[^2]: https://erugo.app
[^3]: https://zipline.diced.sh
[^4]: https://www.reddit.com/r/selfhosted/comments/1cuubgj/best_way_to_share_a_file_privately_only_for_the/
[^5]: https://forum.cloudron.io/topic/9624/cloudreve-self-hosted-file-management-and-sharing-system
[^6]: https://forum.cloudron.io/topic/10233/zipline-a-sharex-file-screenshot-upload-server-alternative-to-xbackbone
[^7]: https://noted.lol/zipline/
[^8]: https://github.com/cloudreve/Cloudreve/blob/master/README.md
[^9]: https://github.com/loong64/Cloudreve
[^10]: https://github.com/ErugoOSS/Erugo

View File

@@ -1,537 +0,0 @@
# Warpbox.dev Design & Requirements SelfHosted File Transfer Platform
## 1. Vision and Goals
Warpbox.dev is a selfhosted, opensource file transfer and lightweight file hosting service inspired by PsiTransfer, transfer.sh, and Gofile, but built entirely in Go with a minimal vanilla JS/CSS frontend. It must be easy to rebrand and run as a multitenant or singletenant instance (e.g., `warpbox.dev`) while also supporting fully selfhosted private deployments via Docker.[^1][^2][^3]
Core goals:
- Anonymous, frictionless uploads (similar to PsiTransfer / transfer.sh).[^2][^3][^1]
- Optional “account mode” with folders, file management, and dashboards similar to Gofile.[^4][^5][^6]
- Admin console for moderation, statistics, and storage management.[^7]
- Firstclass CLI and curl upload support for automation.[^8][^9][^2]
- Highquality link previews (Discord, social, chat apps) with working image/video embeds where possible via Open Graph/Twitter Card metadata.[^10][^11][^12]
- Simple, secure API for custom clients, including ShareX integration.
## 2. HighLevel Feature Set
### 2.1 Anonymous Upload Mode
Anonymous upload mode mimics PsiTransfer and transfer.sh: anyone can upload without an account and immediately receive a shareable link.[^3][^1][^2]
Required features:
- Draganddrop web UI for uploading one or many files.
- Upload very large files (configurable max size; resumable where possible).
- Generated “bucket”/“upload” ID with random token in URL (e.g., `/d/{id}` or `/b/{id}`).
- Optional onetime download links and max downloads / max days, similar to transfer.sh/PsiTransfer.[^13][^2][^3]
- Automatic expiration and cleanup based on configured policies.
- Optional passwordprotected buckets (AES or equivalent; password known only to the user, stored hashed/encrypted serverside), like PsiTransfers passwordprotected download list.[^14][^13][^3]
- Option to download all files in a bucket as zip/tar.gz.[^13][^3]
Nicetohave:
- Simple frontpage with “quick upload”, recent uploads for the current browser session only, and copylink buttons.
- Humanreadable short URLs in addition to long random IDs.
### 2.2 Authenticated User Mode ("SelfHosted Gofile")
Authenticated mode should approximate a lightweight, selfhosted Gofile: users can register/log in, create folders, and manage uploads.[^5][^6][^15][^4]
Required features:
- User accounts (email + password, or OAuth providers as optional later).
- Folder hierarchy for each user (root + nested folders).
- Upload files into specific folders from the web UI.
- Perfile metadata: filename, size, contenttype, upload date, last accessed, owner, visibility (public/unlisted/private), expiration, password.
- Perfolder metadata: name, created at, parent folder, total size, number of files.
- File actions: rename, move, delete, set expiration, set visibility, set password.
- Folder actions: rename, move, delete (with safety checks), share folder as “public gallery”.
- User dashboard: list of uploads, sort/filter, basic stats (total storage, bandwidth used, recent activity).
Nicetohave:
- Guest tokens like Gofile uses for pseudoaccounts attached to a browser session.[^16]
- Tagging of files and folders.
- Simple search (by filename, extension, MIME type, tags) within a users space.
### 2.3 Admin Backend & Management
Admin capabilities should go beyond PsiTransfers simple `/admin` bucket list and provide real operational visibility.[^1][^7][^13]
Required features:
- Admin authentication (separate role from regular users; ideally rolebased access control with roles such as `admin`, `moderator`, `support`).
- Admin dashboard with:
- Total files, total size on disk, total bandwidth used.
- Daily/weekly/monthly uploads and downloads.
- Top files by size, downloads, or bandwidth.
- Top users by storage usage and bandwidth.
- Bucket/file management:
- Search by ID, filename, user, IP, or date range.
- View metadata and status of an upload bucket.
- Force delete file/bucket.
- Extend or shorten expiration date.
- Lock or disable a user account.
- Abuse/moderation tools:
- Flag content as suspected TOS violation.
- Mark user or IP as blocked (no further uploads).
- Optionally integrate with external abuse reporting or virus scanning services (later).
Nicetohave:
- Audit logs of admin actions.
- Config UI for systemwide settings: max file size, default expiration, storage backend, mail settings, etc.
## 3. Upload and Download Flows
### 3.1 Web Uploads
Web uploads should behave similarly to PsiTransfer: responsive UI, multiple files, resumable uploads where possible.[^14][^3][^13]
Core behaviors:
- Draganddrop multiple files or folder selection.
- Progress bars per file and aggregated progress.
- Resumable uploads for large files using tus.io or a similar resumable upload protocol, which is widely used in PsiTransferlike tools and other selfhosted solutions.[^17][^14]
- Graceful error handling: network failures, timeouts, file too large, storage quota exceeded.
### 3.2 CLI / Curl Uploads
Curl upload support is a hard requirement and should be as simple as transfer.shs interface.[^18][^9][^8][^2]
Required behaviors:
- Basic upload:
```bash
curl --upload-file ./file.ext https://warpbox.dev/file.ext
```
Returns a direct URL on stdout (e.g., `https://warpbox.dev/d/{id}/file.ext`).[^19][^2]
- Multiple files via multipart form:
```bash
curl -F file=@file1 -F file=@file2 https://warpbox.dev/upload
```
- HTTP headers or query parameters for options, similar to transfer.sh:
- `Max-Downloads` max download count before expiration.
- `Max-Days` max lifetime in days.[^2]
- Optional `X-Warpbox-Password` to set a download password.
- Optional `X-Warpbox-Folder` target folder for authenticated uploads.
- Simple oneliner helper function in docs (bash/zsh/fish/PowerShell) inspired by transfer.sh examples.[^9][^2]
Nicetohave:
- `warpbox` CLI wrapper (Go binary) as an alternative to raw curl.
### 3.3 Download Behavior
Downloads must be simple but efficient and support range requests for partial downloads/resume.[^14]
Required behaviors:
- Support HTTP range requests (`Accept-Ranges`) so download managers and browsers can resume incomplete downloads.[^14]
- Correct `Content-Type`, `Content-Length`, and `Content-Disposition` headers.
- Optional streaming mode for media (see section 5) when content type is `video/*` or `image/*`.
- Respect maxdownloads and expiration on every request.
## 4. API Design
### 4.1 General Principles
The API should be a simple JSONoverHTTP REST API secured with tokenbased authentication for privileged operations.
General conventions:
- Base path: `/api/v1`.
- Authentication:
- User API tokens (per account) for user endpoints.
- Admin API tokens (different scope) for admin endpoints.
- Request/response: JSON bodies for metadata, multipart/formdata for file upload.
- Pagination for list endpoints (`page`, `pageSize` or `limit`/`offset`).
### 4.2 Public/Anonymous Endpoints
- `POST /api/v1/upload`
- Anonymous upload.
- Accepts multipart form `file` field(s).
- Optional form fields or headers: `max_downloads`, `max_days`, `password`, `filename`.
- Response: bucket ID, list of file URLs, expiration.
- `GET /api/v1/buckets/{id}`
- Returns limited metadata about anonymous bucket if public.
- `DELETE /api/v1/buckets/{id}` (with delete token)
- If bucket was created anonymously, return a management/delete token in the response URL (similar to Gofile management links and other anonymous file hosts).[^4]
### 4.3 Authenticated User Endpoints
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/refresh`
- `GET /api/v1/me` profile, usage quotas.
Folders:
- `GET /api/v1/folders` list root folders.
- `POST /api/v1/folders` create folder.
- `GET /api/v1/folders/{id}` folder details + paginated files.
- `PATCH /api/v1/folders/{id}` rename/move.
- `DELETE /api/v1/folders/{id}`.
Files:
- `POST /api/v1/folders/{id}/upload` upload into folder via multipart.
- `GET /api/v1/files/{id}` file metadata.
- `PATCH /api/v1/files/{id}` change filename, visibility, expiration, password, folder.
- `DELETE /api/v1/files/{id}`.
### 4.4 Admin Endpoints
- `GET /api/v1/admin/stats/overview`
- Global metrics: total size, total files, uploads/downloads per day.[^7]
- `GET /api/v1/admin/files` / `GET /api/v1/admin/buckets`
- Search/filter by user, size, date, IP, status.
- `PATCH /api/v1/admin/files/{id}`
- Force delete, change expiration, lock.
- `GET /api/v1/admin/users`
- `PATCH /api/v1/admin/users/{id}` change roles, lock user.
- `GET /api/v1/admin/logs` optional; audit logs.
## 5. Embeds and Social Link Previews
### 5.1 Discord and Social Embeds Basics
Discord and other platforms primarily rely on Open Graph meta tags (`og:*`) and Twitter Card tags for rich embeds.[^11][^12][^20][^10]
Key tags supported by Discord:
- `og:title` title of the page.
- `og:description` description text under the title.
- `og:image` preview image.
- `og:url` canonical URL.
- `og:site_name` small text above the title.
- Optional `theme-color` sets the colored left border of the embed in Discord.[^12][^11]
Discord also reads Twitter Card tags, with `twitter:card` recommended as `summary_large_image` for large previews.[^10][^12]
### 5.2 Behavior for Image and Video Files
Image embeds:
- For `image/*` files, serve a landing page (`/v/{id}`) that includes:
- `og:type = "website"`.
- `og:title` = filename or usersupplied title.
- `og:description` = short description or “Shared via Warpbox”.
- `og:image` = direct URL of the image.
- `og:url` = landing page URL.
- `og:site_name` = brand/site name.
- `theme-color` = configurable brand color.[^11][^12][^10]
Video embeds:
- Many platforms restrict video embeds to a whitelist of providers; Discord only allows video playback via OG/OEmbed for a small set of providers like YouTube.[^21][^20][^12]
- For generic video files (`video/*`), fully inline video playback in Discord is not reliable, but the best practice is:
- Set `og:image` to a generated thumbnail.
- Use `og:title`, `og:description`, `og:url` as above.
- Provide a direct download/stream link in the embed description.
Document and generic files:
- Use a generic icon preview or firstpage thumbnail (for PDFs) as `og:image`.
### 5.3 OEmbed Considerations
Discord supports oEmbed but only for whitelisted providers; for most selfhosted sites, Open Graph + Twitter Card tags are the primary mechanism.[^20][^12][^11]
Nicetohave:
- Provide an OEmbed endpoint (`/oembed`) to maximize compatibility with clients that do support custom providers.
## 6. ShareX and Desktop Integration
### 6.1 ShareX Custom Uploader
ShareX can integrate with custom uploaders via `.sxcu` configuration files, which specify the request URL, file field name, headers, and URL patterns.[^22][^23][^24][^25]
Requirements:
- HTTP endpoint suitable for ShareX:
- `POST https://warpbox.dev/api/v1/upload` for anonymous.
- `POST https://warpbox.dev/api/v1/folders/{id}/upload` for authenticated.
- Accept multipart form field `file` or `sharex` (configurable) with the uploaded file.[^25]
- Support `Authorization` header with API key/token, as many setups do.[^24][^22][^25]
- Respond with JSON containing a direct link, e.g. `{ "url": "https://warpbox.dev/d/{id}/file.ext" }`.
The project should ship example `.sxcu` files and documentation on how to import them, matching typical ShareX guides.[^23][^22][^25]
### 6.2 Warpbox.exe Helper
Warpbox should ship a small `warpbox.exe` for Windows and an equivalent shell script for Linux/macOS.
Concept:
- `warpbox.exe` is a simple CLI wrapper around curl that:
- Reads configuration (server URL, API key, default folder) from a config file.
- Provides basic commands: `warpbox upload file.ext`, `warpbox url` etc.
- Optionally generates a `.sxcu` file tailored to the instance.
- For Linux/macOS, a `warpbox.sh` shell script:
- Validates presence of `curl`.
- Wraps example curl commands.
- Documentation:
- Stepbystep instructions to set up ShareX with Warpbox using the `.sxcu` file and API token, based on common patterns in existing tutorials.[^22][^23][^24][^25]
## 7. Architecture Overview (Golang + Vanilla JS/CSS)
### 7.1 Backend Architecture (Go)
Core stack:
- Language: Go.
- HTTP framework: standard library `net/http` or lightweight router (e.g., chi, gorilla/mux) as an implementation detail.
- Persistence options:
- PostgreSQL or MySQL for structured data (users, files, folders, tokens, stats).
- Optional SQLite for singleuser or small instances.
- Storage backend:
- Local filesystem (mount volume in Docker) for simple setups.
- Pluggable S3compatible backend for scalable deployments, similar to how transfer.sh supports S3.[^26]
Major components:
- API server: handles REST endpoints, authentication, and business logic.
- Upload engine: streaming uploads to disk/S3; optional resumable uploads (tus.iolike protocol implementation or existing Go tus library).[^17][^14]
- Download server: efficient rangeenabled file serving.
- Admin module: stats aggregation, moderation, storage quotas.
- Background workers:
- Expiration/cleanup of old files and buckets.
- Pregeneration of thumbnails for images/videos.
- Periodic stats aggregation for dashboard.
### 7.2 Frontend Architecture (Vanilla JS/CSS)
The frontend should be frameworkfree to minimize footprint and keep rebranding simple.
Key pages:
- Landing / anonymous upload.
- Auth pages: login, register, password reset.
- User dashboard (folders/files grid or list view).
- File/bucket details view.
- Admin dashboard.
Implementation notes:
- Use vanilla JS `fetch` API for calling the backend.
- Minimal build tooling; optionally a simple bundler for minification.
- CSS:
- Modular BEMstyle classes, or simple utility classes.
- Dark/light themes with CSS variables for easy rebranding.
## 8. Deployment, Docker, and FromScratch Image
### 8.1 Docker Image Strategy
Warpbox should ship an official Docker image with a multistage build, which is common best practice for Go services.[^26]
Recommended Dockerfile approach:
1. **Builder stage**
- Base: `golang:1.x-alpine`.
- Fetch dependencies, build statically linked binary with `CGO_ENABLED=0`.
2. **Runner stage**
- Base: `FROM scratch`.
- Copy binary from builder.
- Copy static assets (HTML, JS, CSS) into an internal directory.
- Expose port (e.g., 8080).
- Set entrypoint to the Go binary.
This keeps the runtime image extremely small while supporting a fully selfcontained binary.[^26]
### 8.2 Configuration and Environment
- Configuration via environment variables:
- `WARPBOX_DB_DSN`, `WARPBOX_STORAGE_PATH` or S3 credentials.
- `WARPBOX_MAX_FILE_SIZE`, `WARPBOX_DEFAULT_EXPIRATION_DAYS`.
- `WARPBOX_BRAND_NAME`, `WARPBOX_BRAND_COLOR` for rebranding.
- `WARPBOX_ADMIN_EMAIL`, `WARPBOX_ADMIN_PASSWORD` for bootstrapping.
- Provide Docker Compose examples for:
- Warpbox + Postgres + Traefik/NGINX reverse proxy.
- Warpbox with S3 backend.
## 9. Security, Privacy, and Abuse Handling
### 9.1 Security Baselines
- All access via HTTPS (left to reverse proxy, but documented).
- Strong password hashing (Argon2id, bcrypt) for user credentials.
- CSRF protection for web UI forms (if cookies used).
- Strict API authentication with bearer tokens over HTTPS.
- Rate limiting for anonymous uploads to prevent abuse.
Nicetohave:
- Contenttype and file extension validation.
- Optional integration with virus scanning (e.g., ClamAV or thirdparty services), similar to transfer.shs optional virus scanning feature.[^26]
### 9.2 Privacy and Anonymity
- Anonymous uploads should not require registration and may log minimal metadata (IP, user agent) only for abuse prevention.
- Clear configuration for retention of logs and metadata.
- Optional “privacy mode” where IPs are hashed or truncated.
### 9.3 Abuse and Legal Considerations
- Admin tools to quickly remove illegal or infringing content and ban abusive users/IPs.[^7]
- TOS/Acceptable Use Policy recommended for public instances.
## 10. SEO and Discoverability
Even though Warpbox links are primarily shared directly, basic SEO ensures public pages index correctly.
Required SEO features:
- Proper `<title>` and `<meta name="description">` tags on public/view pages.
- Canonical URLs using `>`.
- Open Graph and Twitter Card tags as described in section 5.[^12][^10]
- `robots.txt` with sensible defaults (e.g., disallow admin endpoints, allow public galleries).
- Sitemap for discoverable public galleries (optional, enabled only for public instances).
## 11. Rebranding and Theming
Warpbox should be easy to fork and rebrand, or to configure branding at runtime.
Required:
- All exposed names, titles, and meta tags use configurable brand name.
- Primary colors defined as CSS variables and customizable via config.
- Logo and favicon paths configurable (e.g., mount custom assets in Docker volume).
Nicetohave:
- Simple theme system (JSON/YAML theme configs) to quickly switch branding.
## 12. QualityofLife Features and Future Enhancements
Potential QoL features inspired by existing selfhosted file sharing platforms and user expectations:[^13][^1][^7][^26]
- Draganddrop upload that works across desktop and mobile.
- Clipboard helpers: oneclick copy link, copy embed code.
- QR code generation for download URLs.
- Simple “text paste” mode for sharing snippets (Gistlike).
- Optional email notification to the uploader when a file is first downloaded.
- Lightweight analytics for individual files (download count, last download time).
- Multilanguage support (i18n).
- Public folder galleries with grid view of images/videos.
These are not necessary for v1 but can significantly improve the perceived polish and usability of Warpbox.
## 13. Minimal Viable Product (MVP) Scope
To keep the first version shippable, the following is a suggested MVP:
- Anonymous file uploads (web + curl) with:
- Max file size config.
- Expiration (by days) and optional max downloads.
- Basic landing page with Open Graph image preview.
- Simple user accounts with:
- Singlelevel “folders” or collections.
- Upload into folders via web UI.
- Basic dashboard listing files and folders.
- Admin panel:
- Login.
- Global stats (total size, total files, recent uploads).
- Search and delete files.
- API:
- `POST /api/v1/upload` for anonymous.
- Basic authenticated upload and file management endpoints.
- Docker multistage build with `FROM scratch` runner and minimal configuration variables.
Future iterations can then add:
- Resumable uploads.
- Advanced admin analytics.
- ShareX presets and warpbox.exe.
- S3 backend and multinode scaling.
- Thumbnails, galleries, and richer embeds.
---
## References
1. [GitHub - psi-4ward/psitransfer: Simple open source self-hosted file ...](https://github.com/psi-4ward/psitransfer) - Simple open source self-hosted file sharing solution. It's an alternative to paid services like Drop...
2. [transfer.sh](https://transfer.sh) - Easy and fast file sharing from the command-line.
3. [PSiTransfer - A Simple Open Source Self-hosted File Sharing Solution](https://ostechnix.com/psitransfer-simple-open-source-self-hosted-file-sharing-solution/) - PSiTransfer is a simple and open source file sharing utility used to share your files locally or glo...
4. [Gofile.io 匿名文件分享平台,不限文件大小和下载速度- 网盘](https://uzbox.com/en/www/upload/gofile-html) - Gofile Introduction Gofile.io is a free and anonymous file sharing platform where users can quickly ...
5. [Understanding Gofile: A Gateway to Anonymous File Sharing - Oreate AIwww.oreateai.com blog understanding-gofile-a-gateway-to-anonymous...](https://www.oreateai.com/blog/understanding-gofile-a-gateway-to-anonymous-file-sharing/9c83fd65ea686b69aa88ebe3ced1944b) - Gofile.io offers free anonymous file sharing with no data limits while emphasizing privacy through e...
6. [Gofile.io: Free, anonymous file sharing with no limits - AlternativeTo](https://alternativeto.net/software/gofile-io/about/) - Enjoy free, anonymous file sharing without data limits. Upload, store, and stream media files at max...
7. [Erugo - Self-Hosted File Sharing Platform](https://erugo.app) - Erugo is a powerful, self-hosted file-sharing platform that puts you in complete control of your dat...
8. [transfer.sh command - github.com/dutchcoders ...](https://pkg.go.dev/github.com/dutchcoders/transfer.sh)
9. [transfer.sh/examples.md at main · dutchcoders/transfer.sh](https://github.com/dutchcoders/transfer.sh/blob/main/examples.md) - Easy and fast file sharing from the command-line. Contribute to dutchcoders/transfer.sh development ...
10. [How to Add Social Media Embeds To Your Website (Open Graph ...](https://www.howtogeek.com/devops/how-to-add-social-media-embeds-to-your-website-open-graph-meta-tags/) - The configuration for these embeds is done using <meta> tags, usually in the header of your site. Th...
11. [Discord Website Embeds - Nora's Hideout - GitHub Pages](https://analogfeelings.github.io/knowledge/discord-website-embeds/) - A Discord website embed is composed of several HTML meta tags, and optionally, an OEmbed JSON file. ...
12. [Discord Embed Meta Tags: Complete Reference - opengraphplus.com](https://opengraphplus.com/consumers/discord/tags) - Discord supports oEmbed, but only for whitelisted providers (YouTube, Twitch, Spotify, etc.). For mo...
13. [PsiTransfer is a Free Self-hosted File Transfer Solution - MEDevel.com](https://medevel.com/psitransfer-x/) - Features. No accounts, no logins; Mobile friendly responsive interface; Supports many and very big f...
14. [PsiTransfer - psi.cx](https://psi.cx/2017/psitransfer/) - Simple open source self-hosted file sharing solution with robust, resumeable up- and downloads of la...
15. [Send Large Files - Up to 5GB Free - Gofile](https://gofile.to/about) - Gofile is the simplest way to send your files around the world. We host the Internet!
16. [KasimKaizer/gofileioupload: A simple package to upload ...](https://github.com/KasimKaizer/gofileioupload) - A simple package to upload files to gofile.io. Contribute to KasimKaizer/gofileioupload development ...
17. [Self hosted file transfer that can resume from connection interruptions.](https://www.reddit.com/r/selfhosted/comments/1qhb5l5/self_hosted_file_transfer_that_can_resume_from/) - Hi, I'm new to this subreddit and hope this is the right place to ask this. I am trying to host my o...
18. [Upload files from command line using tranfer.sh](https://blog.kiprosh.com/upload-files-from-command-line-using-tranfer-sh/) - Ever stuck on a remote server with some file? Needed to upload/download file from remote server? Fin...
19. [GitHub - jahands/transfer.sh: Easy and fast file sharing from the command-line.](https://github.com/jahands/transfer.sh) - Easy and fast file sharing from the command-line. Contribute to jahands/transfer.sh development by c...
20. [What standard does Discord use when generating embeds from links?](https://www.reddit.com/r/discordapp/comments/1hd7mku/what_standard_does_discord_use_when_generating/) - What meta-tags (I suppose it's meta tags, anyway) does Discord look for when generating things like ...
21. [How can I add an OG meta tag that embeds a video? - Stack Overflow](https://stackoverflow.com/questions/66478860/how-can-i-add-an-og-meta-tag-that-embeds-a-video) - I have been trying to embed a video in my HTML page using OG Meta Tag and it does not work apparentl...
22. [ShareX | S.EE Docs](https://s.ee/docs/sharex/) - Open ShareX · Go to Destinations > Custom uploader settings · Click Import, then either: Select the ...
23. [Setting up ShareX and Image Uploaders | New Day RP](https://newdayrp.com/threads/setting-up-sharex-and-image-uploaders.66990/) - Here are a few general guides to using ShareX with an uploader service, along with general recommend...
24. [Custom domain for your images with ShareX - DEV Community](https://dev.to/thomasbnt/custom-domain-for-your-images-with-sharex-3bmi) - To upload your images, you need to make it request on your server and especially on a file. There ar...
25. [ShareX CUSTOM Domain/URL Setup - YouTube](https://www.youtube.com/watch?v=CeXy3iB-aHA) - How to setup ShareX with your own custom domain or URL, this guide details the configuration and req...
26. [transfer.sh Deploy Guide - Zeabur](https://zeabur.com/templates/OMT6RO) - Key Features · Upload files from terminal with curl, wget, or any HTTP client · Built-in web upload ...

View File

@@ -1,450 +0,0 @@
# Warpbox.dev Product Strategy and Feature Prioritization
## Executive Summary
Warpbox.dev aims to be a selfhosted, opensource file transfer and lightweight file hosting service combining the frictionless, anonymous uploads of tools like transfer.sh and PsiTransfer with the persistent accounts and dashboards of services like Gofile, while remaining simple to deploy and rebrand. Public expectations for this category focus on reliability for large uploads, privacy (including anonymous and passwordprotected sharing), clean and mobilefriendly UI, and basic moderation tools when running public instances. The existing design requirements and UI/UX style guide are broadly aligned with these needs; the main product work is to trim nonessential features from the early stages, strengthen abusehandling and privacy defaults, and ensure the anonymous upload flow is extremely fast and trustworthy.[^1][^2][^3][^4]
At a high level, the roadmap should prioritize: (1) a solid anonymous upload MVP with curl/web support and predictable expirations; (2) robust anonymous mode with better housekeeping, resumable uploads, and a minimal admin view; (3) simple account mode with folders and management UI; (4) a practical admin panel with moderation and storage visibility; and only then (5) advanced features like public galleries, SEO, ShareX helpers, multiple storage backends, theming, and QoL extras. Some proposed features—like oEmbed endpoints, full textpaste mode, or very rich analytics—are nicetohave and can be postponed until real usage demonstrates demand.[^2][^4][^1]
## Market and User Expectations
### Why selfhosted file transfer is attractive
Selfhosted file sharing platforms appeal to:
- Developers and power users who want curl/CLI uploads, ShareX integration, and control over limits and retention.[^5][^6]
- Small teams and communities who need a lightweight alternative to heavy groupware like Nextcloud but still want user accounts and simple dashboards.[^7]
- Privacyconscious users who prefer running their own infrastructure instead of trusting thirdparty hosts with anonymous file sharing.[^3][^8]
Key expectations that appear repeatedly in discussions and product docs:
- Simple, fast uploads from browser and command line, with no mandatory registration.
- Configurable limits (file size, expiration, download count) and optional password protection.
- Direct links that work well in chat, with basic link previews and thumbnails.
- Reasonable abuse controls if the instance is exposed to the public internet (blocking IPs, deleting content, logging).
Warpboxs vision of combining anonymous uploads, optional accounts, a small admin console, and firstclass CLI/ShareX support is well aligned with these expectations.[^1][^2]
### User expectations around anonymity and privacy
Anonymous file sharing users typically expect:
- Minimal logging of IPs and user agents, ideally configurable by the operator.[^8][^3]
- No forced account creation.
- Optional passwords and short expirations to reduce longterm footprint.
Projects like QRClip emphasize usercontrolled deletion, offline encryption, and not storing identifiable metadata as differentiators, showing there is demand for privacycentric approaches. Warpboxs privacy and abuse section already proposes optional privacy modes, IP hashing, and clear retention policies; these are important to keep and should be emphasized in documentation and defaults rather than treated as later niceties.[^3][^1]
### Trends in file sharing and storage
Broader filesharing trends in 20252026 include:
- Stronger focus on security (encryption, zeroknowledge, twofactor auth) and compliance in SaaS offerings.[^4]
- Increasing importance of resumable uploads (often via tus) for reliability with large files and unreliable networks.[^9]
- Growth in CLI tools and custom uploaders, especially around ShareX and developer workflows.[^6][^5]
- Desire for lightweight, mobilefriendly UIs rather than heavy monolithic dashboards.[^7]
Warpboxs planned tusstyle resumable uploads, CLI wrapper, and ShareX support are all aligned with these trends and should be treated as “earlytomidroadmap” priorities rather than longterm optional extras.[^9][^1]
## Synthesized Vision and Positioning
Warpbox is best positioned as:
> A Gobased, selfhosted file transfer and lightweight file host that makes anonymous and authenticated sharing deadsimple, with firstclass CLI and ShareX integration, clean mobilefirst UI, and straightforward admin controls.
Differentiation compared with existing projects (PsiTransfer, transfer.sh, Gofile, Erugo, Zipline, Cloudreve) should center on:
- A single cohesive product that gracefully scales from personal singleuser deployments to multitenant public instances, using the same codebase and UX patterns.
- Opinionated MVP that focuses on anonymous uploads and minimal accounts, instead of trying to be a full Dropbox/Nextcloud competitor.
- Strong story around rebranding and theming, so service providers can easily whitelabel Warpbox for their own domains.
The two provided documents already describe this positioning implicitly; the product strategy document should make it explicit and help decide which features belong in which stage.
## Combined Feature Inventory
The design requirements and UI/UX guide together define a large surface area. This section merges them into thematic groups and comments on importance.
### Core anonymous upload flow
From the requirements document and UI/UX guide:
- Browser upload:
- Draganddrop multifile upload in a centered card, with clear “Drop files to upload or click to browse” messaging.[^10][^1]
- Perfile and aggregated progress bars and inline error states for size limits or network issues.
- Configurable max file size, expiration, and optional max downloads.
- Optional password protection for buckets/files.
- Option to download all files in a bucket as zip/tar.gz.[^1]
- CLI/curl upload:
- transfer.shstyle singlefile and multifile curl interface with flags or headers for expiration, max downloads, password, and target folder.[^6][^1]
- Simple oneliner shell function in the docs to encourage adoption.
- Anonymous management:
- Returned management/delete token to allow later deletion of anonymous buckets.[^1]
These features map directly to what users of transfer.sh, PsiTransfer, and similar tools expect, and they are nonnegotiable for Warpboxs value proposition. The anonymous web and curl flows form the core of Stage 12.[^2][^6]
### Authenticated user mode
Key elements from the requirements:
- Basic accounts (email/password; OAuth later) with peruser quotas.
- Folder hierarchy (root + nested folders) and optional collections.
- Perfile metadata (size, type, upload date, visibility, expiration, password) and actions (rename, move, delete, set expiration, set password).
- Perfolder metadata and actions, including sharing as public galleries.
- User dashboard listing uploads with basic stats.[^1]
From the UI/UX guide:
- Postlogin dashboard with sidebar navigation, “My files” table, upload button, and clear empty states.[^10]
- Progressive enhancement from flat collections to full folder trees, breadcrumbs, table/card views, and search.[^10]
These bring Warpbox closer to a lightweight Gofile/Cloudrevelike experience. For many selfhosters, having a personal, persistent space is a strong motivator to adopt a new platform, but anonymous upload must still work without accounts.[^2]
### Admin console and moderation
From the requirements:
- Admin roles distinct from regular users, with an admin dashboard showing total files, size, bandwidth and uploads/downloads per period.[^1]
- Searchable file/bucket list with ability to delete, change expiration, lock users, and block IPs.
- Moderation tools: flag content, mark accounts or IPs as abusive, optionally integrate virus scanning.[^1]
From the UI/UX guide:
- Admin overview with metrics cards, simple charts, recent uploads, and recent flags.[^10]
- Files and Users tables with filters, status indicators, and clear destructive confirmations.[^10]
As soon as Warpbox is deployed on a public domain with anonymous uploads, operators will need at least minimal moderation and storage oversight; this supports prioritizing a basic admin view fairly early (Stage 23), with richer analytics later.
### Embeds, public views, and SEO
From the requirements:
- Highquality link previews through Open Graph and Twitter Card tags, with thumbnails for images/videos and generic icons for other files.[^1]
- Optional oEmbed endpoint for clients that support custom providers.
- SEO basics: titles, descriptions, canonical URLs, robots.txt, optional sitemaps for public galleries.[^1]
From the UI/UX guide:
- Clean file landing pages with prominent download button, link copy, and metadata (expires, downloads, owner).
- Gridstyle public gallery views with filters and mobilefriendly layout.[^10]
Given that many links will be shared in Discord, Slack, and messaging apps, correct Open Graph tags and simple landing pages are more important than deep SEO, at least initially. Public galleries and sitemaps are more niche and can be deferred.[^11][^12][^13]
### ShareX and desktop integration
From the requirements and UI/UX guide:
- ShareX custom uploader compatibility via simple JSON configuration, supporting both anonymous and authenticated endpoints.[^14][^1]
- Example .sxcu files shipped with the project plus documentation.
- A tiny warpbox.exe or shell script that wraps curl uploads and can generate ShareX configs.
- Settings → Integrations section for managing API tokens and downloading configs.[^10]
ShareX usage is very common in the selfhosted ecosystem (many users explicitly search for “best selfhosted ShareX uploader”), so firstclass support is a strong differentiator and a good early midstage feature, especially for technical audiences.[^15][^5]
### Architecture and deployment
From the requirements:
- Go backend with standard HTTP router.
- PostgreSQL/MySQL (or SQLite for small instances) for structured data.[^1]
- Local filesystem storage plus pluggable S3compatible backends.
- Background workers for expiration, thumbnail generation, and stats.
- Minimal vanilla JS/CSS frontend.
- Multistage Docker image, configuration via environment variables, Docker Compose examples, and a small FROM scratch runtime image.[^1]
These choices match best practices for selfhosted services and align with expectations on r/selfhosted and similar communities (Docker, simple env configuration, resourceefficient binaries). No major architectural feature in this area feels obsolete; the main risk is scope creep in storage and scaling features too early.[^16][^7]
### Security, privacy, and abuse handling
The requirements specify:
- HTTPS via reverse proxy, secure password hashing, CSRF protection, and strict tokenbased API authentication.[^1]
- Rate limiting for anonymous uploads.
- Optional contenttype validation and virus scanning.
- Configurable logging and privacy modes (hashing/truncating IPs).
- Admin tools for quick removal of illegal content and banning abusive users/IPs, plus TOS guidance.[^1]
Given rising concerns around abuse, many operators will treat security and moderation as toptier requirements. These aspects should be considered part of the core platform, not optional extras, but the implementation can start with a minimal set (basic rate limiting, IP blocking, file deletion) and evolve.[^17]
### UI/UX design language and accessibility
The UI/UX guide defines:
- Global UX principles (simplicity, progress feedback, predictable navigation, mobilefirst, accessibility, configurable branding).[^10]
- Application shell and navigation patterns for anonymous, authenticated, and admin users.
- Visual language (typography, color system, buttons, cards, tables) and component guidelines.
- Perstage UI specifications that align with the functional stages from the requirements document.
- Interaction patterns (loading, error handling, notifications) and accessibility responsiveness rules.
These are highly valuable and align with positive feedback on other selfhosted tools like Cloudreve, Zipline, and Erugo, which are praised for clean, draganddrop UIs and responsive dashboards. None of this feels obsolete; the risk is underimplementing accessibility or responsiveness compared to what the guide promises.[^2][^10]
## Feature Evaluation: MustHave vs NicetoHave
The following tables summarize which features are critical now, which are strong differentiators, and which can be postponed.
### Core sharing and upload features
| Feature | Importance | Rationale |
| --- | --- | --- |
| Draganddrop anonymous web upload | Musthave (Stage 1) | Baseline expectation from transfer.shstyle tools and modern selfhosted file hosts.[^2][^6] |
| Basic curl upload interface | Musthave (Stage 1) | Key reason developers adopt transfer.sh/PSiTransfer; defines Warpboxs CLI story.[^6][^16] |
| Expiration by days | Musthave (Stage 1) | Essential for storage control and privacy; explicitly requested by users.[^18][^2] |
| Max downloads | Strong differentiator (Stage 2) | Common in Erugo/Gofilestyle tools; not strictly required for MVP but highly appreciated.[^2][^19] |
| Password protection | Strong differentiator (Stage 2) | Users increasingly expect passwordprotected links in anonymous sharing contexts.[^3][^20] |
| Zip/tar of bucket | Nicetohave (Stage 23) | Convenient for multifile sharing; transfer.sh supports it but not mandatory for initial release.[^6] |
| Resumable uploads (tus) | Strong differentiator (Stage 23) | Growing standard for large files and unreliable networks; improves UX significantly.[^9] |
### Account and dashboard features
| Feature | Importance | Rationale |
| --- | --- | --- |
| Simple account registration/login | Musthave (Stage 3) | Enables persistent usage; many selfhosters want personal libraries.[^7][^2] |
| Flat collections / simple folders | Musthave (Stage 3) | Keeps initial account mode usable without full tree complexity.[^10] |
| Full folder hierarchy, breadcrumbs | Nicetohave (Stage 4) | Great for heavy users but can be deferred until core flows are stable.[^10] |
| File search and tagging | Nicetohave (Stage 4+) | Valuable but not essential early; implement keyword search first, tags later. |
| Public folder galleries | Nicetohave (Stage 6+) | Useful for some instance types, but adds complexity (SEO, moderation). |
### Admin and moderation features
| Feature | Importance | Rationale |
| --- | --- | --- |
| Basic admin login + file list + delete | Musthave (Stage 2) | Any public anonymous instance needs a way to remove content quickly.[^17][^1] |
| Global stats (file count, size) | Musthave (Stage 2) | Helps operators manage disk usage and plan capacity.[^1] |
| IP and user blocking | Strong differentiator (Stage 34) | Important for abuse control; can start simple (manual blocks). |
| Advanced analytics (perday charts, top users/files) | Nicetohave (Stage 5+) | Good for large deployments but not needed for personal instances early. |
| Audit logs of admin actions | Nicetohave (Stage 5+) | Adds traceability; relevant mostly for multiadmin setups. |
### Embeds, public views, SEO
| Feature | Importance | Rationale |
| --- | --- | --- |
| Open Graph/Twitter Card tags for file links | Musthave (Stage 2) | Ensures decent previews in Discord, Slack, etc.; relatively low effort.[^11][^12][^13] |
| Thumbnail generation for images/videos | Strong differentiator (Stage 45) | Greatly improves gallery and preview UX; requires background workers.[^1] |
| oEmbed endpoint | Nicetohave (Stage 6+) | Limited number of clients support custom oEmbed; can be postponed.[^13] |
| Sitemaps and SEO for public galleries | Nicetohave (Stage 6+) | Relevant only for instances that want discoverability; default should be privacyfirst. |
### Integrations and QoL features
| Feature | Importance | Rationale |
| --- | --- | --- |
| ShareX custom uploader support + .sxcu examples | Strong differentiator (Stage 4) | Major draw for technical users; many threads ask for ShareXfriendly hosts.[^15][^5] |
| warpbox CLI helper | Nicetohave (Stage 45) | Adds polish; curl alone is sufficient initially.[^16][^6] |
| QR code links | Nicetohave (Stage 5+) | Useful but niche; can wait until core flows are solid.[^3] |
| Text paste mode | Nicetohave (Stage 5+) | Moves Warpbox toward pastebin territory; implement only if demanded. |
| Email notification on first download | Nicetohave (Stage 5+) | Helpful for some workflows but adds mail complexity. |
## Potentially Obsolete or OverScoped Features
Relative to current market expectations, very few proposed features are truly obsolete; most are just early for the MVP. The following should be considered lower priority or conditional on clear demand:
- **Full oEmbed implementation**: Since most messaging apps rely on Open Graph/Twitter tags and reserve oEmbed for a small whitelist of providers, investing heavily here early is unlikely to pay off. Keeping an oEmbed endpoint on the roadmap is fine, but not for early stages.[^13]
- **Rich public SEO and sitemaps**: For a privacy and controloriented product, defaultopen indexing is risky. Public galleries and sitemaps should be optin and belong to later stages focused on “public publishing” use cases.[^1]
- **Comprehensive text paste mode**: Text snippets are useful but can be served by dedicated paste tools. Unless Warpbox is explicitly targeting pastebin users, this should not distract from filecentric features.
- **Highly advanced analytics dashboards**: Operators mainly need to know disk usage, bandwidth, and recent activity. Complex multichart analytics and job dashboards can be reserved for once large operators adopt Warpbox.
No feature in the docs is outright “bad”, but a strict focus on anonymous uploads, basic accounts, and minimal admin tooling will reduce timetovalue and avoid partial implementations of niche capabilities.
## Recommended StagebyStage Roadmap
The existing documents already define stages; this section refines them into a productcentric roadmap with priorities and success criteria.
### Stage 1: Minimal anonymous upload MVP
**Goal:** A firsttime visitor can upload and share a file in seconds without an account, and operators can deploy Warpbox easily on a single server.
**Scope (musthave):**
- Web anonymous upload:
- Singlepage landing with draganddrop, progress bars, and direct link output per file.[^10][^1]
- Configurable max file size (env var) and default expiration in days.
- CLI/curl upload:
- Simple PUT or POST endpoint for single file uploads and returning a URL.[^6]
- Storage and cleanup:
- Local filesystem storage with periodic cleanup based on expiration.[^1]
- Deployment:
- Go backend, singlefile binary, multistage Docker image with basic env configuration and docs.[^1]
- Basic privacy and safety:
- HTTPS via reverse proxy, secure password hashing (for future accounts), basic logging, and single admin account environment bootstrap.
**Out of scope (for later):** accounts, admin dashboards, advanced embed behavior, S3, ShareX helpers.
**Success criteria:**
- Upload + share + download works reliably for small and moderate files.
- Deployment instructions verified on at least one common homeserver setup (Docker Compose on a VPS or home lab).
```mermaid
flowchart LR
A[Visitor opens landing page] --> B[Drag & drop or browse files]
B --> C[Show upload queue + progress]
C --> D[Files stored on disk]
D --> E[Show direct links]
E --> F[Recipient downloads via link]
```
### Stage 2: Robust anonymous mode and housekeeping
**Goal:** Anonymous upload becomes productionready for public instances with better control and minimal admin tooling.
**Scope (musthave / strong differentiators):**
- Advanced options panel on upload page: expiration presets, max downloads, optional password.[^10][^1]
- Management tokens for anonymous buckets (delete/extend expiration).[^1]
- Basic admin view:
- Login as admin.
- Metrics cards (total files, total storage, uploads last 24h).
- Recent uploads table with View/Delete.[^10]
- Open Graph/Twitter Card metadata for file links, with basic image thumbnails or filetype icons.[^11][^1]
- Rate limiting for anonymous uploads and simple IP blocklist (e.g., config file + admin UI toggle).
**Optional if time allows:** bucket zip download, preliminary resumable uploads for large files.
**Success criteria:**
- Operators can run a public instance without constant manual intervention.
- Links look acceptable when pasted into Discord/Slack.
```mermaid
gantt
dateFormat YYYY-MM-DD
title Early Roadmap
section Stage 1
Landing & upload UI :done, s1a, 2026-01-01, 2026-02-01
Basic curl endpoint :done, s1b, 2026-01-15, 2026-02-05
Local storage & cleanup :active, s1c, 2026-01-20, 2026-02-10
section Stage 2
Advanced options panel : s2a, 2026-02-11, 2026-03-01
Admin basic dashboard : s2b, 2026-02-15, 2026-03-10
OG/Twitter metadata : s2c, 2026-02-20, 2026-03-05
```
### Stage 3: User accounts and simple personal space
**Goal:** Provide a minimal persistent personal file space without overwhelming complexity.
**Scope:**
- Authentication:
- Email/password login, registration, password reset.[^10][^1]
- User dashboard:
- “My files” view with table listing name, collection, size, uploaded date, and actions.
- Singlelevel collections/folders (no deep nesting yet).
- Upload into collections, reuse upload component from anonymous mode.[^10]
- Perfile metadata and simple management (rename, move collection, delete, set expiration).
**Success criteria:**
- Users can log in, see their uploads, and perform basic file management.
- Anonymous and authenticated paths coexist cleanly without confusing navigation.
### Stage 4: Resumability, full folders, and better UX
**Goal:** Make Warpbox suitable for heavier users who upload large files and manage deeper hierarchies.
**Scope:**
- Resumable uploads:
- Implement tusstyle resumable uploads on both web and curl/CLI where feasible.[^9][^1]
- Full folder hierarchy:
- Folder tree with breadcrumbs, table and card views, and search.[^10]
- Thumbnail support for common image types.
- Improved admin filters (by status, owner, date range) and simple IP/user blocking.
**Success criteria:**
- Large uploads survive intermittent network issues.
- Power users can organize files into nested structures and find them efficiently.
### Stage 5+: Admin depth, galleries, ShareX, and theming
**Goal:** Add highvalue differentiators once the core platform is solid.
**Key themes:**
- Admin depth: more charts, audit logs, storage backend management UI, background jobs view.[^10][^1]
- Public galleries and SEO: grid galleries, optin sitemaps, better search for public content.
- ShareX and CLI polish: .sxcu downloads, warpbox.exe wrapper, more detailed docs.[^5][^14]
- Theming and i18n: runtime branding, dark/light themes, translation system.[^10]
- QoL extras: QR codes, text paste mode, email notifications, lightweight perfile analytics.[^10][^1]
Implementation order within Stage 5 can be driven by actual user feedback and which audiences adopt Warpbox first (personal power users vs public hosting providers).
## Additional ProductLevel Recommendations
### Focus documentation on privacy tradeoffs
Given that anonymous file sharing can be used for both legitimate privacy and abuse, the documentation should clearly explain what metadata is logged, how long it is retained, and how operators can configure privacy modes. Explicit sections like “Running a public instance safely” and “Recommended default expirations” will help reduce surprises and align expectations.[^8][^3]
### Provide opinionated defaults
Opinionated defaults that align with public needs:
- Default expiration of a few days for anonymous uploads.
- Reasonable max file size out of the box (e.g., 12 GB), with clear guidance on raising it.
- Anonymous uploads enabled but ratelimited; easy config flag for privateonly mode (no anonymous uploads) for organizations that require authentication.[^7]
### Optimize for mobile and lowfriction flows
Many users will paste or open links on mobile devices; the UI/UX guide already calls for mobilefirst design, but implementation should be measured in practice (e.g., by testing on lowend Android devices). Minimizing JS bundle size, ensuring draganddrop degrades gracefully to file pickers, and making copylink buttons tapfriendly will materially impact satisfaction.[^10]
### Treat ShareX and developer workflows as firstclass
In selfhosted communities, a large share of adoption for tools like Zipline and XBackbone comes from their ShareX integration and developerfriendly APIs. Documentation and UI affordances (e.g., “Download ShareX config” buttons, code snippets for curl) should be treated as core UX, not just appendices.[^15][^5]
### Iterate based on real usage
After the first public release, telemetry (if selfhosters optin) or community feedback (issues, discussions) should drive priorities in Stage 5+: whether to focus more on galleries, text pastes, multitenant features, or advanced moderation. The existing documents give a rich menu of options; the product strategy should stay flexible rather than committing to all of them up front.
---
## References
1. [Warpbox.dev-Design-Requirements-Self-Hosted-File-Transfer-Platform-1-2.md](https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/attachments/1163977444/1ef4ab03-2be4-45a2-83ee-83ad7ab2df5d/Warpbox.dev-Design-Requirements-Self-Hosted-File-Transfer-Platform-1-2.md?AWSAccessKeyId=ASIA2F3EMEYEVN7E24TP&Signature=O4kKB3jenT890FUKa9%2FrA5nul5k%3D&x-amz-security-token=IQoJb3JpZ2luX2VjEJr%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJGMEQCIAyG19zmuFhlL2UeKyPSZn58fIrhtJU%2BMdDAZgh17g4IAiBa1DyzFCdh%2BMBidkUsbmYe9q55lSaiX66MaMPMI7cNvCrzBAhjEAEaDDY5OTc1MzMwOTcwNSIMddIc8Drx%2FXeqo7BaKtAEzQN5ddE8FXBKr5SPFbkfTV5cAkvnOv73maZf7dgkzUOg3uIfeFMW4WMPi9qXOUPMgHovGqkUlBt%2BuqW8FMbobs3WbtSLVdIuMvYWAqO4FY9S%2BLbgjXiQB%2FA9xxDwfNobZZDmGgDTWwTvKvPo4JY3q1LAMtMByLwHzFYuDz%2BphOWmln2xlECZ7FqaMFYBi6lezeMsssyr8hqkFWm3EIDY%2BBsCKq19wa3AK4R5C3LZGsTK6%2BskHxRvUxJ2egRf88jlJq4NyQHXseO9d%2BTvnbAFIFICYNlysLwpvlToOzxJ5r%2B0eDKjgm7o9iIjiuusjLZPyJE92br94xOaYCJoM2erdgqpzlmHO%2BDsRxfQT3V8lxK5WUhoNkz7v4QtPshQ1jP0LylN2HhvXl8Zuq7WJJ4D%2FZEpzO176cM3fN4csz6Dl72lWkypHrcz3vZw5I%2BWhe%2BpoEMaiIG%2FchZ9iTbvdoqT5%2Fp30b4d61ULmILrGBuOHbyP6Clow82ooPMj0RMTidyP9lcTVmgtZZO%2BkS5QBm767s26TSk6elKQxx6O8F3kw2NUaUH4UeoDD9HRF1nVpJkzHtgC0SDBhRCRhWa7bHqO%2BmEj%2FIxWT1okRfiLQ3spU0c%2B%2F08poXAgFj6Niy7fSXNrUgGOqfNVE7h5b316NuuqhGU3D%2FVEDOaNq91rlQ92Mum4LCPeWXI1z3PvMyCJ3oUqmoc%2FwCXo9lsG1n6ROCM6hA7sH6pAcHQsq00q6hmoV1iv2yqTh36ux9GurDWlr4yZuZqJ%2BZhW3248gAh6Qu6BtDC7u9DQBjqZAVpwwx7M8shuHZPn6tsxX7JdyDQeL0ek3Xvc0HTVpyhtvQ02066e4P1L6R2k4UtTYz22DnnMCSvg3Z8MhSskxJ%2F%2BXPTBFmEBWmg5xaigUGFHjWTVn9%2FQKrCmali%2FfA53Kyyf1JQJJ2S5sls3kMR2u9EZF%2FJNRAEszCClusH0Ax6lrjCe6cTzO2XrU4X1NNRUbXAmxMS0ET8NAw%3D%3D&Expires=1779706766) - # Warpbox.dev Design & Requirements SelfHosted File Transfer Platform
## 1. Vision and Goals
Wa...
2. [Erugo - Self-Hosted File Sharing Platform](https://erugo.app) - Ready to take control of your file sharing? Join the community of users who value privacy and simpli...
3. [Anonymous File Sharing - QRClip](https://www.qrclip.io/blog/anonymous-file-sharing) - Anonymous file sharing gives you the freedom to share files without worrying about unwanted tracking...
4. [what is the best file sharing platform in 2025 - Savenet Solutions](https://www.savenetsolutions.ie/post/what-is-the-best-file-sharing-platform-in-2025-1) - 1. Tresorit Best for Enterprise-Grade Security · 2. Sync.com Best Affordable Secure Option · 3. ...
5. [ShareX - Custom host upload script + online image gallery - GitHub](https://github.com/booskit-codes/sharex-php-gallery) - This project provides a custom host upload script that seamlessly integrates with ShareX and an onli...
6. [transfer.sh](https://transfer.sh) - Easy and fast file sharing from the command-line.
7. [Looking for a lightweight open-source self-hosted file sharing solution.](https://www.reddit.com/r/selfhosted/comments/1pe02hv/looking_for_a_lightweight_opensource_selfhosted/) - Users must log in to access anything. Some users (like the project maintainers) need read-write perm...
8. [Hidden networks and anonymous file sharing. A guide to privacy](https://hackyourmom.com/en/pryvatnist/pryhovani-merezhi-ta-anonimnyj-obmin-fajlamy-putivnyk-z-pryvatnosti/) - In this article, you'll learn about best practices and tools for maintaining anonymity on the Intern...
9. [Resumable uploads | Supabase Features](https://supabase.com/features/resumable-uploads) - Supabase's Resumable Uploads feature enables reliable transfer of large files, allowing uploads to b...
10. [Ok-where-s-the-.md-document-for-me-to-look-at_-I.md](https://ppl-ai-file-upload.s3.amazonaws.com/web/direct-files/attachments/1163977444/c88904c3-0a4f-43a6-9443-ef3cf2ce9043/Ok-where-s-the-.md-document-for-me-to-look-at_-I.md?AWSAccessKeyId=ASIA2F3EMEYEVN7E24TP&Signature=R9%2FDaCVYgON39rjgM2L%2BIt%2FlJAI%3D&x-amz-security-token=IQoJb3JpZ2luX2VjEJr%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLWVhc3QtMSJGMEQCIAyG19zmuFhlL2UeKyPSZn58fIrhtJU%2BMdDAZgh17g4IAiBa1DyzFCdh%2BMBidkUsbmYe9q55lSaiX66MaMPMI7cNvCrzBAhjEAEaDDY5OTc1MzMwOTcwNSIMddIc8Drx%2FXeqo7BaKtAEzQN5ddE8FXBKr5SPFbkfTV5cAkvnOv73maZf7dgkzUOg3uIfeFMW4WMPi9qXOUPMgHovGqkUlBt%2BuqW8FMbobs3WbtSLVdIuMvYWAqO4FY9S%2BLbgjXiQB%2FA9xxDwfNobZZDmGgDTWwTvKvPo4JY3q1LAMtMByLwHzFYuDz%2BphOWmln2xlECZ7FqaMFYBi6lezeMsssyr8hqkFWm3EIDY%2BBsCKq19wa3AK4R5C3LZGsTK6%2BskHxRvUxJ2egRf88jlJq4NyQHXseO9d%2BTvnbAFIFICYNlysLwpvlToOzxJ5r%2B0eDKjgm7o9iIjiuusjLZPyJE92br94xOaYCJoM2erdgqpzlmHO%2BDsRxfQT3V8lxK5WUhoNkz7v4QtPshQ1jP0LylN2HhvXl8Zuq7WJJ4D%2FZEpzO176cM3fN4csz6Dl72lWkypHrcz3vZw5I%2BWhe%2BpoEMaiIG%2FchZ9iTbvdoqT5%2Fp30b4d61ULmILrGBuOHbyP6Clow82ooPMj0RMTidyP9lcTVmgtZZO%2BkS5QBm767s26TSk6elKQxx6O8F3kw2NUaUH4UeoDD9HRF1nVpJkzHtgC0SDBhRCRhWa7bHqO%2BmEj%2FIxWT1okRfiLQ3spU0c%2B%2F08poXAgFj6Niy7fSXNrUgGOqfNVE7h5b316NuuqhGU3D%2FVEDOaNq91rlQ92Mum4LCPeWXI1z3PvMyCJ3oUqmoc%2FwCXo9lsG1n6ROCM6hA7sH6pAcHQsq00q6hmoV1iv2yqTh36ux9GurDWlr4yZuZqJ%2BZhW3248gAh6Qu6BtDC7u9DQBjqZAVpwwx7M8shuHZPn6tsxX7JdyDQeL0ek3Xvc0HTVpyhtvQ02066e4P1L6R2k4UtTYz22DnnMCSvg3Z8MhSskxJ%2F%2BXPTBFmEBWmg5xaigUGFHjWTVn9%2FQKrCmali%2FfA53Kyyf1JQJJ2S5sls3kMR2u9EZF%2FJNRAEszCClusH0Ax6lrjCe6cTzO2XrU4X1NNRUbXAmxMS0ET8NAw%3D%3D&Expires=1779706766) - <img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margi...
11. [Best self-hosted file hosting software?](https://www.reddit.com/r/selfhosted/comments/17si22x/best_selfhosted_file_hosting_software/)
12. [ShareX - The best free and open source screenshot tool for Windows](https://getsharex.com) - ShareX is a free and open source program that lets you capture or record any area of your screen and...
13. [Sharry is a self-hosted file sharing web application. - GitHub](https://github.com/eikek/sharry) - Sharry allows to share files with others in a simple way. It is a self-hosted web application. The b...
14. [Re-Review the document you have sent me and research UI and UX, make sure that for every point you also expand research into UI/UX, where you talk about how the UI should look like, how it should be consistent, I need you to write examples of how the...
...ications and what were the most requested UI/UX features and insert them into the stages, into the descriptions, etc... Basically completing the existing document
Please treat this reply as a UI/UX Document, it's a "design/style guide" for the UI/UX](https://www.perplexity.ai/search/eefb77dd-ab0f-4eda-96ae-fbfb74f62cfe) - Ive created a dedicated Warpbox.dev UI/UX design & style guide in markdown that completes your exis...
15. [Best Self Hosted ShareX image uploader - LowEndTalk](https://lowendtalk.com/discussion/180651/best-self-hosted-sharex-image-uploader) - XBackbone is my vote. It has been solid, consistent, and reliant. Works with ShareX out of the box a...
16. [README ¶](https://pkg.go.dev/github.com/Mikubill/transfer)
17. [[ Removed by moderator ]](https://www.reddit.com/r/selfhosted/comments/1nuurbs/decentralized_file_sharing/) - [ Removed by moderator ]
18. [File Drop: secure file upload and share for Enterprises](https://nextcloud.com/blog/file-drop-convenient-and-secure-file-exchange-for-enterprises/) - Send files and folders with just a few clicks to one or multiple customers. Create personal links fo...
19. [martadams89/gofile-dl: Download all directories and files in ... - GitHub](https://github.com/martadams89/gofile-dl) - ✓ Password Protection: Supports SHA-256 password hashing for protected content; ✓ Recursive Download...
20. [photoview/photoview: Photo gallery for self-hosted ...](https://github.com/photoview/photoview) - Photo gallery for self-hosted personal servers. Contribute to photoview/photoview development by cre...

View File

@@ -1,123 +0,0 @@
# Warpbox.dev Deep Research Report
## Executive summary
The attached documents describe Warpbox.dev as a self-hosted, open-source file transfer and lightweight file-hosting product that tries to combine four normally separate product archetypes: the no-account simplicity of PsiTransfer and transfer.sh, the account-and-folder convenience of Gofile, the operational visibility of an admin console, and the automation friendliness of CLI, API, and ShareX integrations. The intended implementation is opinionated and pragmatic: Go backend, vanilla JS/CSS frontend, Docker-first deployment, local or S3-compatible storage, link previews via Open Graph/Twitter Cards, and a staged UX that starts with anonymous upload and expands toward accounts, admin, embeds, automations, and theming.
Market evidence suggests there is real whitespace for a product with exactly this blend. The closest public demand signal is not a formal niche TAM number, which is hard to find in open sources for self-hosted anonymous file transfer, but a repeated pattern across self-hosting forums, large-file-transfer reviews, and competitor positioning: users want a lightweight WeTransfer-style flow without account friction, with big-file reliability, resumability, expiry/password controls, and enough admin and branding to use it publicly or with clients. They also routinely complain that broad suites like Nextcloud feel slow or like overkill for this job, while managed products like WeTransfer constrain free usage and attract pricing complaints.
The most important product insight is strategic rather than technical: Warpbox should not try to beat Nextcloud at collaboration breadth or Cloudreve at full cloud-drive depth. Its strongest position is "self-hosted transfer-first platform" that is faster to understand than Nextcloud, more durable and operator-friendly than minimal anonymous upload tools, and more automation-capable than most branded transfer SaaS products. In practical terms, the recommended MVP is anonymous web plus curl upload, polished link generation, delete token or owner controls, expiry/password/max-downloads, a minimal admin console, and security hardening. Accounts, folders, resumable uploads, ShareX, S3 backends, and gallery features should follow quickly after validation, not all at once.
Assumptions used in this report: jurisdiction is unspecified, so compliance guidance is framed as a cross-jurisdiction baseline rather than legal advice; monetization is not specified in the documents, so recommendations are inferred from competitor pricing and user sentiment; and for open-source native competitors, GitHub stars are used as the closest broadly comparable public "user signal" when standardized review scores are unavailable.
## Product extracted from the attached documents
The requirements document defines a product with three core jobs. First, it must let anonymous users upload and share files immediately, with configurable size limits, expiry, max-downloads, optional password protection, archive download, and direct share URLs. Second, it must optionally become a personal file space with accounts, nested folders, metadata, dashboards, visibility controls, and public galleries. Third, it must support operators through an admin console with RBAC, search, moderation, quotas, and statistics. The same document also makes API and automation first-class requirements, specifying `/api/v1`, token-based auth, anonymous and authenticated upload endpoints, admin endpoints, ShareX `.sxcu` support, and curl-compatible upload semantics.
The UI guide complements that functional brief with a very specific interaction model: one clear primary action per view, mobile-first responsiveness, accessible-by-default controls, and progressive disclosure rather than feature crowding. The landing page is intentionally centered on a single large drop zone, immediate progress feedback, and post-upload copy-link actions. More advanced controls such as expiry, password, and max-downloads sit in a collapsed "Advanced options" area. Authenticated areas use a sidebar shell, simple dashboard tables, breadcrumbs for hierarchy, and a strong distinction between end-user actions and admin actions. Embeds, ShareX/API token management, storage-backend management, branding, i18n, and operational job views are all explicitly staged in later phases rather than crammed into version one.
Taken together, the documents imply the following product architecture and technical requirements: a Go service layer, a framework-light frontend, streaming uploads, HTTP range-supported downloads, background jobs for cleanup and thumbnails, structured persistence for users/files/folders/tokens/stats, pluggable object storage, configurable branding, and reverse-proxy-friendly Docker deployment. They also imply several unstated but necessary implementation choices: a durable job queue, a clear tenant isolation model if "multi-tenant" is real and not just branding, signed or otherwise hard-to-guess object access patterns, audit/event tables, server-side validation of file type and extension, and an observability layer for uploads, download failures, and abuse events. Those are not explicitly spelled out everywhere, but they are the minimum connective tissue required to make the documented roadmap coherent.
The core user flows implied by the two documents are straightforward. Anonymous users should arrive, drag files, optionally set expiry/download/password controls, watch per-file progress, and leave with share links immediately. Logged-in users should additionally choose a target folder, later browse or move files, manage metadata, and share folders or items externally. Admins need a separate flow for global metrics, recent uploads, search by identifier or user, moderation, and storage or backend operations. These flows are consistent with the documents' repeated emphasis on low cognitive load for first-time users and richer capabilities only when the user has context or privilege.
The flow below summarizes the best-fit "core journey" implied by the documents.
```mermaid
flowchart TD
A[Landing page] --> B{Signed in?}
B -->|No| C[Anonymous drop zone]
B -->|Yes| D[Choose folder or create folder]
C --> E[Optional advanced settings: expiry, password, max downloads]
D --> F[Upload files with per-file and aggregate progress]
E --> F
F --> G{Upload succeeds?}
G -->|No| H[Inline error, toast, retry]
G -->|Yes| I[Show share links and copy actions]
I --> J[Recipient opens landing page]
J --> K{Password / expiry / download count valid?}
K -->|No| L[Access denied or expired state]
K -->|Yes| M[Preview, open in browser, or download]
M --> N[Usage and admin metrics updated]
```
## Market demand and user needs
Open public evidence points to demand across three overlapping segments. The first is self-hosters who want a "WeTransfer but on my own server" experience and explicitly reject heavyweight suites. In recent self-hosted threads, users ask for exactly that, describing Nextcloud as "slow" or "overkill" and requesting private upload with public download, expiration, password protection, random URLs, resumable uploads, and no account requirement for the uploader.
The second segment is professional external file exchange, especially agencies, photographers, editors, and media teams. These users consistently need to move files in the 5 GB to 50 GB range and often much more, while minimizing client friction. In editor workflows, users describe Dropbox requests failing on larger files, WeTransfer free tiers being too restrictive, FTP being too complex, and MASV or similar products winning because they are simple for contractors and can handle very large packages.
The third segment is broader mainstream transfer demand. WeTransfer's own current marketing states 12M+ monthly active users and 43K+ enterprises using the service daily, which is strong evidence that the "send a large file quickly, often without forcing the recipient into an account" use case remains large and commercially important. At the same time, WeTransfer's updated free plan now caps users at 3 GB and 10 transfers per 30 days, which creates obvious space for self-hosted and white-label alternatives.
Four personas recur most clearly in the evidence. The privacy-minded self-hoster wants data control, Docker deployment, hard-to-guess links, low operational load, and enough moderation or rate-limiting to expose the service publicly without inviting abuse. The SMB or agency operator wants branded send-and-receive portals, expiry, password options, share analytics, and simple client-side UX. The media and production user wants resumable large-file transfer, folder preservation, high throughput, and automations like watch folders, ShareX, or CLI. The IT and security lead wants auditability, retention policy, SSO or OIDC, malware-scanning hooks, quotas, and a workable takedown or abuse workflow.
The top pain points are strikingly consistent. Users want fewer accounts and less recipient friction, but they also want real controls. They complain about short-lived links, upload failures on big files, weak progress visibility, lack of resume, pricing that scales poorly, and tools that are either "too light" to be trustworthy in production or "too heavy" for straightforward file exchange. On managed tools, review summaries repeatedly praise simplicity and link-based sharing while criticizing expiration limits, restricted free tiers, and pricing.
Desired features, based on recurring asks and what competitors emphasize, cluster into a compact set: no-account send and receive, large-file reliability, explicit upload progress, resumability, passwords and expiry, branded request pages, folder preservation, link previews, and admin visibility. If Warpbox nails those before broadening into full collaboration, it will match a real public need rather than a hypothetical one.
On willingness to pay, the evidence is clearer for adjacent markets than for self-hosted open source specifically. Hobbyist and self-hosted users often anchor around "free core plus self-host infra" and actively seek cost-effective alternatives to Dropbox or WeTransfer. Professionals accept modest recurring fees for convenience or branding, as seen in WeTransfer's paid plans and Filemail's $6, $14, and $24 monthly tiers. Enterprises accept much higher spend when the offer includes support, SSO, compliance posture, and predictable governance, as shown by Nextcloud enterprise pricing from 71.29 EUR per user per year upward and Filemail or MASV enterprise models. The best inference is that Warpbox should keep the core transfer product free or very low friction, and monetize through managed hosting, white-labeling, enterprise support, SSO/compliance packs, or premium storage/governance rather than through a hard paywall on basic sending.
## Competitive landscape
The competitive field has a clear structure. Minimal anonymous tools such as PsiTransfer optimize for speed and simplicity. Sharing-first self-hosted tools such as Pingvin Share and Erugo add passwords, expiry, and nice landing pages. Automation-first tools such as Zipline lean into ShareX and dashboard workflows. Broader platforms such as Cloudreve and Nextcloud offer much deeper storage, search, and ecosystem value, but at the cost of extra complexity. Managed SaaS such as WeTransfer and Filemail win on polish and mindshare, but they preserve the classic tradeoff of convenience versus ownership, limits, and recurring spend.
For open-source direct competitors, standardized user review scores are often sparse, so GitHub stars are used below as a public community-signal proxy. That is not the same thing as a verified satisfaction rating, but it is the most consistent cross-product signal available for this subset.
| Product | Notable features | Pricing | User rating / signal | Strengths | Weaknesses | Positioning |
|---|---|---|---|---|---|---|
| PsiTransfer | Anonymous upload, resumable up/downloads via tus, one-time downloads, zip/tar.gz, password-protected lists, basic `/admin`. | Free OSS, self-host infra only. | 1.9k GitHub stars. | Very close to Warpbox's anonymous-transfer core, especially for reliability. | Limited account/library depth and thin operator layer relative to Warpbox's ambition. | Minimal self-hosted transfer tool. |
| Pingvin Share | Link sharing, "unlimited" size constrained by disk, expiry, visitor limits, passwords, email recipients, reverse shares, OIDC/LDAP, ClamAV, local/S3. | Free OSS, self-host infra only. | 4.7k GitHub stars, but archived since June 29, 2025. | Strong transfer-first feature set with good security controls. | Upstream archive status raises maintainability risk. | Self-hosted WeTransfer alternative. |
| Zipline | ShareX/file upload server, dashboard, gallery, metrics, folders, tags, URL shortening. | Free OSS, self-host infra only. | 3.2k GitHub stars. | Best-in-class fit for power users, screenshots, and automation-heavy workflows. | Narrower admin/compliance story than Cloudreve or Nextcloud. | Power-user upload automation and ShareX backend. |
| Cloudreve | Multi-cloud storage, direct transfer to storage providers, drag-drop and resumable uploads, metadata search, previews, share links, themes, PWA, i18n. | Community edition free. Pro pricing is listed from $89.9 and $299.9 for higher license tiers. | 27.9k GitHub stars. | Deepest feature breadth and strongest storage-backend story among direct self-hosted peers. | Closer to a cloud-drive platform than a clean transfer-first product, which increases complexity. | Full self-hosted file management and sharing platform. |
| Erugo | Elegant UI, multiple files, progress tracking, instant preview, folder support, large-file support, automatic resume, human-friendly links, passwords, expiry, download limits, branding, dashboard. | Free OSS, donation-supported. | 1.1k GitHub stars. | Closest visible UX competitor to the Warpbox brief. | Younger ecosystem and smaller public footprint than Cloudreve or Nextcloud. | Secure, polished self-hosted WeTransfer-style sharing. |
| Nextcloud Files / File Drop | Self-hosted file sync/share, public upload via File Drop, passwords, expiry, audit tracking, AV scanning, encryption, clients, broad ecosystem. | Free self-hosted community use. Enterprise starts at 71.29 EUR per user per year. | 4.6/5 on Capterra from 442 reviews. | Enterprise-ready controls and ecosystem. | Frequently perceived as overkill or slower for lightweight transfer-only use cases. | Full collaboration suite with secure file exchange. |
| WeTransfer | No-account send flow, file requests, password protection, broad recognition. Free tier now 3 GB and 10 transfers per 30 days. Starter adds up to 300 GB per 30 days. | Paid plans start around $12/month according to Capterra. | 4.78/5 on Capterra from about 2,947 reviews. | Extremely familiar workflow and low recipient friction. | Free-tier restrictions and pricing complaints are a recurring weakness. | Mainstream managed transfer SaaS. |
| Filemail | Large-file transfer, resumable transfers, activity tracking, branding, password protection, receive requests, SSO, 2FA, audit logs, E2EE on higher tiers. | Free basic tier. Official pricing snippets show Personal $6/mo, Pro $14/mo, Business $24/mo, Enterprise custom. | 4.6/5 on GetApp from 41 reviews. | Strong professional send/receive and compliance-oriented features. | SaaS, not self-hosted, and feature richness is tied to recurring spend. | Professional secure large-file transfer. |
The practical lesson from this landscape is simple. Warpbox should not position itself as another generic "drive." It should position as the product that combines PsiTransfer's transfer clarity, Erugo's polish, Zipline's automation, and a selective subset of Cloudreve and Nextcloud's operator controls without inheriting their full cognitive and operational weight. That combination is uncommon in the current field.
## Compliance, privacy, security, and accessibility
Because jurisdiction is unspecified, the correct baseline is a layered compliance model. For privacy, any public or business-facing Warpbox deployment should assume GDPR-style obligations if it processes user emails, IP addresses, file metadata, audit records, or uploaded content that contains personal data. The European Commission's GDPR overview highlights core principles such as lawfulness, transparency, purpose limitation, data minimization, storage limitation, integrity/confidentiality, and accountability. EDPB guidance on Article 25 reinforces "data protection by design and by default," while its breach materials stress that certain personal-data breaches must be notified within 72 hours where required. In practical product terms, this means privacy notices, configurable retention, least-data defaults, deletion flows, purpose-bounded logs, and documented breach playbooks should be treated as product requirements, not policy afterthoughts.
If the product is offered to Californians and crosses the relevant business thresholds, the CCPA as amended by the CPRA becomes relevant. The California Attorney General's guidance emphasizes rights to know, delete, correct, opt out of sale or sharing, limit sensitive-personal-information use, and receive required notices. This matters even for a transfer product because emails, IPs, account credentials, support logs, billing data, and uploaded files can all become regulated data depending on usage.
For public hosting instances, user-generated-content obligations also matter. In the United States, the Copyright Office explains that DMCA Section 512 safe harbors depend on cooperating with rightholders, acting expeditiously on infringement notices, and designating a DMCA agent. In the EU, operators should review whether their deployment falls within Digital Services Act hosting-service obligations and implement at least a robust notice-and-action flow, moderation logging, and repeat-abuse handling. The attached Warpbox requirements already anticipate this by calling for moderation tools, blocking, and TOS/AUP support.
On security, Warpbox sits in a high-risk class because file-upload surfaces are notoriously abuse-prone. OWASP's File Upload Cheat Sheet and unrestricted-file-upload guidance make server-side type validation, extension allowlisting, malicious-file scanning, safe storage, and separation between uploaded content and executable application surfaces table stakes. The Authentication Cheat Sheet, OWASP ASVS, and NIST SSDF together imply strong password storage, secure session or token handling, MFA at least for admins, authorization checks on every object access, secret management, secure defaults, and verification throughout the development lifecycle. The Warpbox requirements are directionally aligned with this, calling for HTTPS, Argon2id or bcrypt, CSRF protection where cookies are used, bearer token auth, rate limiting, optional AV scanning, and minimal anonymous metadata logging.
Accessibility is not optional if the app is to serve a general public or public-sector buyer. WCAG 2.2 defines the main web-content accessibility baseline, organized under perceivable, operable, understandable, and robust principles. Section 508 remains the relevant U.S. federal procurement framework, while EN 301 549 is the key EU ICT accessibility procurement standard. The attached UI guide already aligns well with this direction, explicitly requiring keyboard navigation, focus visibility, semantic HTML, ARIA for complex widgets, screen-reader announcements, responsive breakpoints, and consistent language and icon use. The implementation discipline now matters more than the intent.
One additional compliance branch is optional but commercially important: payments. If Warpbox later introduces in-product billing, recurring subscriptions, or seat management, payment processing should be architected through a PSP so card data never touches Warpbox systems directly. If cardholder data does touch the product, PCI DSS becomes relevant.
## Recommendations for product, UX, and go to market
The highest-priority product decision is scope control. The documents already contain a sound staged model, and that staging should be preserved. The recommended MVP is: anonymous upload via web and curl, immediate link output, delete token or simple owner control, expiry/password/max-downloads, range-enabled downloads, a simple branded landing page with Open Graph support, basic admin stats and search/delete, HTTPS and rate-limiting, and a clean Docker deployment story. This is consistent with the attached MVP notes and best matches the strongest user demand signals.
The next priority layer should be selective, not expansive. The first post-MVP additions worth funding are resumable uploads, authenticated folders, API tokens, ShareX configs, S3-compatible storage, and stronger moderation or audit tooling. Those features are repeatedly requested by real users and sharply improve product utility without dragging Warpbox into full-suite territory too early. Search, galleries, text-paste mode, QR codes, email notifications, and richer analytics are valuable, but they are polish and expansion features, not market-entry features.
From a UX perspective, the attached UI guide is strong and should be treated as a constraint, not a suggestion. Warpbox should preserve a single dominant action on each page, keep advanced controls collapsed until needed, make progress and post-upload actions unmistakable, and show limits clearly before the user starts uploading. The documents' insistence on mobile-first layouts, visible focus states, stable terminology, and obvious recovery from error is exactly right for this category, where many users are occasional senders rather than daily power users. In practice, that means: no hidden copy-link affordances, no ambiguous "upload complete" states, no account wall for receiving, and no admin UI that uses only icons.
The best market positioning sentence is likely some version of: "A self-hosted transfer-first platform in Go: simpler than Nextcloud, more robust than anonymous upload toys, and more automatable than most managed transfer tools." That message is credible because it maps to actual gaps in the field. PsiTransfer shows the appeal of minimalism, but not enough product depth. Nextcloud shows the value of governance, but also the cost of breadth. WeTransfer shows strong mainstream demand, but also the frustration caused by caps and pricing. Warpbox can win by sitting between those poles.
Go-to-market should therefore be dual-track. The first track is OSS and self-hosting adoption: GitHub, Docker Hub, installation docs, a one-click Compose example, migration guides from WeTransfer/PsiTransfer/Pingvin Share, and posts in self-hosting communities that explicitly emphasize "public upload, private control, no heavy suite required." The second track is commercial packaging for teams: managed hosting, custom domain and branding, SSO/OIDC, audit logs, malware scanning hooks, data residency options, and SLA-backed support. That packaging is a better monetization vector than restricting core sending, because the public evidence shows users resent transfer caps and overpriced basics but will pay for governance, enterprise support, and operational convenience.
An illustrative milestone roadmap, derived from the attached staged design and the recommended scope, is below.
```mermaid
timeline
title Recommended Warpbox milestones
Jun 2026 : Finalize architecture, threat model, API contract
Jul 2026 : Build anonymous web upload, curl upload, link landing pages
Aug 2026 : Add admin basics, delete token flow, retention and abuse controls
Sep 2026 : Private beta with accounts, folders, resumable uploads
Oct 2026 : Ship ShareX configs, API tokens, S3 backend, audit logging
Nov 2026 : Public launch with branding, galleries, localization, managed-hosting offer
```
## Sources, assumptions, and open questions
The evidence base for this report prioritizes the two attached documents first, then official product sites, official GitHub repositories, official legal or standards sources, and finally review and community sources used mainly to understand user pain points and willingness to pay. Product extraction came from the attached Warpbox requirements and UI guide. Competitor features and pricing came primarily from official product sites and repositories. Ratings came from public software review platforms where available, and GitHub stars were used only when standardized rating data was not available for OSS peers. Regulatory and standards guidance came from the European Commission, EDPB, ICO, California Attorney General, U.S. Copyright Office, OWASP, NIST, W3C, ETSI, Section508.gov, and PCI SSC.
Key assumptions used in the analysis are these. "Multi-tenant" was interpreted as at least some tenant-aware branding and admin separation, not necessarily full enterprise-grade hard tenancy. "Secure API" was interpreted as token-scoped auth plus standard secure-development controls, even though the documents do not fully specify session architecture or secret rotation. "User ratings" for OSS competitors were interpreted pragmatically as community traction signals when verified review scores were unavailable. Monetization is inferred because the documents themselves do not specify a business model.
Open questions remain, and they materially affect execution quality. The documents do not fully specify the tenant isolation model, the exact identity architecture for web sessions versus API tokens, the storage abstraction boundary for local versus S3 uploads, whether virus scanning is synchronous or asynchronous, which jurisdictions or sectors are target customers, or whether Warpbox should stay purely OSS or become open-core. I also did not identify a robust, public, narrowly scoped market-size estimate for "self-hosted WeTransfer-like file transfer" specifically, so demand in this report is inferred from competitor adoption, pricing, reviews, and repeated user requests rather than from a clean TAM/SAM/SOM dataset.

View File

@@ -1,32 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
.output
.vinxi
.tanstack/**
.nitro
*.local
# Wrangler / Cloudflare
.wrangler/
.dev.vars
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,64 +0,0 @@
# Warpbox.dev UI Mockup Plan
Build a **static, non-functional mockup** of all key Warpbox.dev pages using shadcn/ui (already installed) + Tailwind. No backend, no real uploads — just realistic UI with seeded dummy data so you can review look & feel.
## Shell & layout
- `src/components/layout/PublicHeader.tsx` — logo, About, Docs, Login, Register.
- `src/components/layout/AppSidebar.tsx` — authenticated sidebar (Dashboard, My files, Shared links, Settings, Admin).
- `src/components/layout/AdminSidebar.tsx` — admin sidebar (Overview, Files, Users, Storage, Settings, Logs).
- `src/components/layout/Footer.tsx` — version, ToS, Privacy.
- Shared `AppShell` wrapper used by authenticated / admin route groups (via TanStack layout routes `_app.tsx`, `_admin.tsx`).
## Routes (each its own file under `src/routes/`)
Public / anonymous:
- `index.tsx` — Stage 1 landing: large drop zone, upload limits panel, "Advanced options" (expiry / max downloads / password), simulated upload list with progress + copy-link rows, post-upload success card.
- `about.tsx` — short marketing/about page.
- `docs.tsx` — docs landing with curl/ShareX snippets.
- `login.tsx`, `register.tsx` — centered auth cards.
- `d.$id.tsx` — public download page (file preview, size, expiry, download button, password prompt variant).
Authenticated (`_app/` layout with sidebar):
- `_app/dashboard.tsx` — metric cards (storage used, files, recent activity) + recent uploads table.
- `_app/files.tsx` — folder tree (left), breadcrumbs, toolbar (New folder / Upload / Share), table+card view toggle, search.
- `_app/shared.tsx` — list of share links with expiry, downloads count, revoke.
- `_app/settings.tsx` — profile, password, API tokens, ShareX config download.
Admin (`_admin/` layout):
- `_admin/overview.tsx` — 4 metric cards, sparkline/bar chart (recharts), recent uploads + recent flags tables.
- `_admin/files.tsx` — filters panel (date / status / owner type / size), files table with bulk actions.
- `_admin/users.tsx` — users table (email, role, storage used, status, actions).
- `_admin/storage.tsx` — backend status (local/S3), usage gauges, cleanup controls.
- `_admin/logs.tsx` — log stream table with level filter.
- `_admin/settings.tsx` — instance branding, limits, retention, SMTP.
## Components
Built with shadcn primitives already present: `card`, `button`, `input`, `table`, `tabs`, `dialog`, `dropdown-menu`, `sidebar`, `progress`, `badge`, `separator`, `sonner`, `chart`.
Reusable mockup pieces:
- `UploadDropzone` (visual only, fake progress on click).
- `FileRow` (name, size, expiry badge, copy-link, delete).
- `MetricCard` (label, value, delta).
- `FolderTree` (static nested list).
- `EmptyState`.
## Data
Single `src/lib/mock-data.ts` with seeded files, users, logs, metrics. No network calls, no Cloud enablement.
## Design system
Keep existing oklch tokens in `src/styles.css`. Add subtle additions only if needed (e.g. `--success`, `--warning` already implied via chart tokens — extend if missing). Mobile-responsive: sidebar collapses to sheet, tables become card lists at `sm`.
## Out of scope (mockup only)
- No real file upload, auth, storage, or API.
- No Lovable Cloud / Supabase.
- No route guards — admin/auth pages are publicly viewable for review.
- No i18n, no theming switcher beyond existing light/dark tokens.
## Navigation aid
Add a small dev-only "Mockup pages" dropdown in the public header listing every route, so you can jump between screens during review.

View File

@@ -1,4 +0,0 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_2026-05-12"
}

View File

@@ -1,8 +0,0 @@
node_modules
dist
.output
.vinxi
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts

View File

@@ -1,6 +0,0 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
[install]
# 24h supply-chain guard: skip package versions published less than a day ago.
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config"]

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -1,40 +0,0 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", ".output", ".vinxi"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-restricted-imports": [
"error",
{
paths: [
{
name: "server-only",
message:
"TanStack Start does not use the Next.js `server-only` package. Rename the module to `*.server.ts` or mark it with `@tanstack/react-start/server-only`.",
},
],
},
],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
eslintPluginPrettier,
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +0,0 @@
{
"name": "tanstack_start_ts",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@cloudflare/vite-plugin": "^1.25.5",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.168.25",
"@tanstack/react-start": "^1.167.50",
"@tanstack/router-plugin": "^1.167.28",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.5",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vaul": "^1.1.2",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "^1.7.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"prettier": "^3.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}

View File

@@ -1,121 +0,0 @@
import { Link, useRouterState } from "@tanstack/react-router";
import {
LayoutDashboard,
FolderOpen,
Share2,
Settings,
Shield,
Files,
Users,
HardDrive,
ScrollText,
Gauge,
Box,
} from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
SidebarHeader,
} from "@/components/ui/sidebar";
import { MockupNav } from "./MockupNav";
const userItems = [
{ to: "/app/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/app/files", label: "My files", icon: FolderOpen },
{ to: "/app/shared", label: "Shared links", icon: Share2 },
{ to: "/app/settings", label: "Settings", icon: Settings },
];
const adminItems = [
{ to: "/admin/overview", label: "Overview", icon: Gauge },
{ to: "/admin/files", label: "Files", icon: Files },
{ to: "/admin/users", label: "Users", icon: Users },
{ to: "/admin/storage", label: "Storage", icon: HardDrive },
{ to: "/admin/logs", label: "Logs", icon: ScrollText },
{ to: "/admin/settings", label: "Settings", icon: Settings },
];
export function AppShell({
variant,
title,
children,
}: {
variant: "user" | "admin";
title: string;
children: React.ReactNode;
}) {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const items = variant === "admin" ? adminItems : userItems;
return (
<SidebarProvider>
<div className="flex min-h-screen w-full">
<Sidebar collapsible="icon">
<SidebarHeader>
<Link to="/" className="flex items-center gap-2 px-2 py-1.5 font-semibold">
<Box className="h-5 w-5 text-primary" />
<span>warpbox.dev</span>
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>{variant === "admin" ? "Admin" : "Workspace"}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((it) => (
<SidebarMenuItem key={it.to}>
<SidebarMenuButton asChild isActive={pathname === it.to}>
<Link to={it.to} className="flex items-center gap-2">
<it.icon className="h-4 w-4" />
<span>{it.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{variant === "user" && (
<SidebarGroup>
<SidebarGroupLabel>Other</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link to="/admin/overview" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
<span>Admin</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
</Sidebar>
<div className="flex flex-1 flex-col">
<header className="flex h-14 items-center gap-3 border-b bg-background px-4">
<SidebarTrigger />
<h1 className="text-sm font-medium text-foreground">{title}</h1>
<div className="ml-auto flex items-center gap-2">
<MockupNav />
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground">
A
</div>
</div>
</header>
<main className="flex-1 bg-muted/30 p-4 md:p-6">{children}</main>
</div>
</div>
</SidebarProvider>
);
}

View File

@@ -1,72 +0,0 @@
import { Link } from "@tanstack/react-router";
import { ChevronDown, FlaskConical } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const groups: { label: string; items: { to: string; label: string }[] }[] = [
{
label: "Public",
items: [
{ to: "/", label: "Landing / Upload" },
{ to: "/about", label: "About" },
{ to: "/docs", label: "Docs" },
{ to: "/login", label: "Login" },
{ to: "/register", label: "Register" },
{ to: "/d/a1b2c3", label: "Download page" },
],
},
{
label: "User",
items: [
{ to: "/app/dashboard", label: "Dashboard" },
{ to: "/app/files", label: "My files" },
{ to: "/app/shared", label: "Shared links" },
{ to: "/app/settings", label: "Settings" },
],
},
{
label: "Admin",
items: [
{ to: "/admin/overview", label: "Overview" },
{ to: "/admin/files", label: "Files" },
{ to: "/admin/users", label: "Users" },
{ to: "/admin/storage", label: "Storage" },
{ to: "/admin/logs", label: "Logs" },
{ to: "/admin/settings", label: "Settings" },
],
},
];
export function MockupNav() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<FlaskConical className="h-4 w-4" />
Mockup pages
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{groups.map((g, i) => (
<div key={g.label}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>{g.label}</DropdownMenuLabel>
{g.items.map((it) => (
<DropdownMenuItem key={it.to} asChild>
<Link to={it.to}>{it.label}</Link>
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,41 +0,0 @@
import { Link } from "@tanstack/react-router";
import { Box } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MockupNav } from "./MockupNav";
export function PublicHeader() {
return (
<header className="border-b bg-background">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
<Link to="/" className="flex items-center gap-2 font-semibold">
<Box className="h-5 w-5 text-primary" />
warpbox.dev
</Link>
<nav className="ml-6 hidden gap-1 md:flex">
<Button asChild variant="ghost" size="sm"><Link to="/about">About</Link></Button>
<Button asChild variant="ghost" size="sm"><Link to="/docs">Docs</Link></Button>
</nav>
<div className="ml-auto flex items-center gap-2">
<MockupNav />
<Button asChild variant="ghost" size="sm"><Link to="/login">Login</Link></Button>
<Button asChild size="sm"><Link to="/register">Register</Link></Button>
</div>
</div>
</header>
);
}
export function Footer() {
return (
<footer className="border-t bg-background">
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-2 px-4 py-4 text-xs text-muted-foreground sm:flex-row">
<div>warpbox.dev · v0.4.2 · self-hosted</div>
<div className="flex gap-4">
<a href="#" className="hover:text-foreground">Terms</a>
<a href="#" className="hover:text-foreground">Privacy</a>
<a href="#" className="hover:text-foreground">Contact</a>
</div>
</div>
</footer>
);
}

View File

@@ -1,51 +0,0 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium cursor-pointer transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,115 +0,0 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -1,49 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,5 +0,0 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -1,47 +0,0 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,32 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -1,101 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -1,49 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -1,177 +0,0 @@
"use client";
import * as React from "react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root,
),
dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -1,55 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -1,240 +0,0 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

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