16 Commits

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

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

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

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

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

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

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

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

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

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

Additionally, update tests in `upload_stage3_test.go` to verify the thumbnail image is rendered and relax the OG image URL matching.
2026-06-03 15:22:58 +03:00
42 changed files with 5658 additions and 243 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

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

@@ -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)
@@ -134,6 +136,8 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
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}/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

@@ -47,7 +47,11 @@ type fileView struct {
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
@@ -55,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 {
@@ -135,7 +141,7 @@ 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)) 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))) imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
imageType := "image/jpeg" imageType := "image/jpeg"
@@ -197,7 +203,7 @@ func fileShareDescription(size, contentType string, expiresAt time.Time) string
if strings.TrimSpace(contentType) == "" { if strings.TrimSpace(contentType) == "" {
contentType = "file" contentType = "file"
} }
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006")) 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 { func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
@@ -238,12 +244,32 @@ 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) { if shouldServeRawSocialMedia(file) {
a.serveFileContent(w, r, box, file, false) a.serveFileContent(w, r, box, file, false)
a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...) 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 return
} }
} }
if file.ProcessingError != "" && !locked {
a.logger.Warn("failed file preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4242, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
if services.BoxHasTrouble(box) && !locked {
a.logger.Warn("failed box preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4246, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
view := a.fileView(box, file) view := a.fileView(box, file)
fileSize := helpers.FormatBytes(file.Size) fileSize := helpers.FormatBytes(file.Size)
title := file.Name title := file.Name
@@ -302,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")...)
@@ -317,6 +353,11 @@ 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 {
@@ -345,8 +386,100 @@ 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 { func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) { if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
return "" return ""
} }
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file) thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
@@ -369,6 +502,62 @@ func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.B
return thumbnail 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.
@@ -437,9 +626,11 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
defer object.Body.Close() defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType) w.Header().Set("Content-Type", file.ContentType)
disposition := "inline"
if attachment { if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name)) disposition = "attachment"
} }
w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name))
if seeker, ok := object.Body.(io.ReadSeeker); ok { if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker) http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else { } else {
@@ -455,6 +646,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
} }
} }
func contentDisposition(disposition, name string) string {
filename := cleanDownloadFilename(name)
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename))
}
func cleanDownloadFilename(name string) string {
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
clean = filepath.Base(clean)
if clean == "" || clean == "." || clean == "/" {
return "download"
}
return clean
}
func asciiFilenameFallback(name string) string {
var fallback strings.Builder
for _, char := range name {
switch {
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';':
fallback.WriteByte('_')
case char <= 0x7e:
fallback.WriteRune(char)
default:
fallback.WriteByte('_')
}
}
clean := strings.TrimSpace(fallback.String())
if clean == "" {
return "download"
}
return clean
}
func readSeekCloser(source io.ReadCloser) io.ReadSeeker { func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
data, err := io.ReadAll(source) data, err := io.ReadAll(source)
if err != nil { if err != nil {
@@ -481,9 +705,25 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
http.Error(w, "password required", http.StatusUnauthorized) http.Error(w, "password required", http.StatusUnauthorized)
return return
} }
for _, file := range box.Files {
if file.Processing {
http.Error(w, "file is still processing", http.StatusAccepted)
return
}
if file.ProcessingError != "" {
a.logger.Warn("zip download blocked by failed file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4244, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
return
}
}
if services.BoxHasTrouble(box) {
a.logger.Warn("zip download blocked by failed box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4248, "box_id", box.ID, "error", services.BoxTroubleReason(box))...)
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
return
}
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip")) w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip"))
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
if err := a.uploadService.WriteZip(w, box); err != nil { if err := a.uploadService.WriteZip(w, box); err != nil {
@@ -513,7 +753,11 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
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),
@@ -521,6 +765,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

@@ -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,7 @@ package handlers
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
@@ -23,6 +24,7 @@ import (
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
"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"
) )
@@ -95,10 +97,65 @@ func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
return 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) icon := a.ogFileIcon(file)
a.serveOGImage(w, r, a.renderFileCard(file, icon)) 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 {
@@ -213,6 +270,174 @@ func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
return canvas 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 { func fileCardInfo(file services.File) string {
switch { switch {
case strings.HasPrefix(file.ContentType, "audio/"): case strings.HasPrefix(file.ContentType, "audio/"):

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

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
} }
@@ -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

@@ -17,6 +17,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"
) )
@@ -121,10 +122,13 @@ func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
body := response.Body.String() body := response.Body.String()
if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) { 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) t.Fatalf("social preview bot did not receive file card metadata: %s", body)
} }
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") { if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
t.Fatalf("download page did not render text thumbnail image: %s", body)
}
if !strings.Contains(body, "Open to preview or download") {
t.Fatalf("social preview body missing preview/download description: %s", body) t.Fatalf("social preview body missing preview/download description: %s", body)
} }
} }
@@ -145,7 +149,7 @@ func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
} }
body := response.Body.String() body := response.Body.String()
if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) { 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) t.Fatalf("social preview bot did not receive file card metadata: %s", body)
} }
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) { if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
@@ -215,6 +219,99 @@ func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
} }
} }
func TestDownloadPageShowsProcessingFailure(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadThroughApp(t, app)
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
box.Files[0].Processing = false
box.Files[0].ProcessingError = "Access Denied."
if err := app.uploadService.SaveBox(box); err != nil {
t.Fatalf("SaveBox returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
request.SetPathValue("boxID", payload.BoxID)
response := httptest.NewRecorder()
app.DownloadPage(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
"Upload processing failed",
"Access Denied.",
"is-failed",
"Failed",
} {
if !strings.Contains(body, want) {
t.Fatalf("download page missing %q: %s", want, body)
}
}
if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) {
t.Fatalf("failed file still exposed download context: %s", body)
}
}
func TestFileDownloadUsesOriginalFilename(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello")
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil)
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFileContent(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
disposition := response.Header().Get("Content-Disposition")
for _, want := range []string{
`attachment;`,
`filename="report final.txt"`,
`filename*=UTF-8''report%20final.txt`,
} {
if !strings.Contains(disposition, want) {
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
}
}
if response.Body.String() != "hello" {
t.Fatalf("body = %q", response.Body.String())
}
}
func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello")
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil)
request.SetPathValue("boxID", payload.BoxID)
request.SetPathValue("fileID", payload.Files[0].ID)
response := httptest.NewRecorder()
app.DownloadFileContent(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
disposition := response.Header().Get("Content-Disposition")
for _, want := range []string{
`inline;`,
`filename="r_sum_ 2026.txt"`,
`filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`,
} {
if !strings.Contains(disposition, want) {
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
}
}
}
func TestResumableUploadFlowCreatesNormalBox(t *testing.T) { func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -721,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
t.Fatalf("NewBanService returned error: %v", err) t.Fatalf("NewBanService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() { return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
jobs.WaitForThumbnailJobs()
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
@@ -728,8 +826,12 @@ func newTestApp(t *testing.T) (*App, func()) {
} }
func uploadThroughApp(t *testing.T, app *App) services.UploadResult { func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
}
func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
t.Helper() t.Helper()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello") request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
request.Header.Set("Accept", "application/json") request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder() response := httptest.NewRecorder()
app.Upload(response, request) app.Upload(response, request)

View File

@@ -1,8 +1,11 @@
package jobs package jobs
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt"
"html" "html"
"image" "image"
"image/color" "image/color"
@@ -16,7 +19,10 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/image/font" "golang.org/x/image/font"
@@ -33,13 +39,21 @@ type ThumbnailJobResult struct {
Failed int Failed int
} }
var thumbnailJobs sync.WaitGroup
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) { func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
thumbnailJobs.Add(1)
go func() { go func() {
defer thumbnailJobs.Done()
box, err := uploadService.GetBox(boxID) box, err := uploadService.GetBox(boxID)
if err != nil { if err != nil {
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error()) logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
return return
} }
if services.BoxHasTrouble(box) {
logger.Warn("thumbnail one-shot skipped trouble box", "source", "thumbnail", "severity", "warn", "code", 4206, "box_id", boxID, "error", services.BoxTroubleReason(box))
return
}
result, err := generateMissingThumbnailsForBox(uploadService, logger, box) result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
if err != nil { if err != nil {
@@ -52,6 +66,10 @@ func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger
}() }()
} }
func WaitForThumbnailJobs() {
thumbnailJobs.Wait()
}
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job { func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
return job{ return job{
name: "thumbnail", name: "thumbnail",
@@ -86,6 +104,9 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
if !box.ExpiresAt.After(now) { if !box.ExpiresAt.After(now) {
continue continue
} }
if services.BoxHasTrouble(box) {
continue
}
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box) boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
result.Scanned += boxResult.Scanned result.Scanned += boxResult.Scanned
@@ -104,30 +125,67 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
if !box.ExpiresAt.After(time.Now().UTC()) { if !box.ExpiresAt.After(time.Now().UTC()) {
return result, nil return result, nil
} }
if services.BoxHasTrouble(box) {
return result, nil
}
changed := false changed := false
for i := range box.Files { for i := range box.Files {
file := &box.Files[i] file := &box.Files[i]
if file.Thumbnail != "" || !needsThumbnail(*file) { if file.Processing || services.FileHasTrouble(*file) {
continue
}
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
if !needsPrimary && !needsScenes && !needsArchive {
continue continue
} }
result.Scanned++ result.Scanned++
if needsPrimary {
thumbnail, err := generateThumbnail(uploadService, box, *file) thumbnail, err := generateThumbnail(uploadService, box, *file)
if err != nil { if err != nil {
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
result.Failed++ result.Failed++
continue } else if thumbnail == "" {
}
if thumbnail == "" {
result.Failed++ result.Failed++
continue } else {
}
file.Thumbnail = thumbnail file.Thumbnail = thumbnail
changed = true changed = true
result.Generated++ result.Generated++
} }
}
if needsScenes {
sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
if err != nil {
logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error())
result.Failed++
} else if sceneThumbnail == "" {
result.Failed++
} else {
file.SceneThumbnail = sceneThumbnail
changed = true
result.Generated++
}
}
if needsArchive {
archiveListing, err := generateArchiveListing(uploadService, box, *file)
if err != nil {
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
result.Failed++
} else if archiveListing == "" {
result.Failed++
} else {
file.ArchiveListing = archiveListing
file.ArchiveListingObjectKey = ""
changed = true
result.Generated++
}
}
}
if changed { if changed {
if err := uploadService.SaveBox(box); err != nil { if err := uploadService.SaveBox(box); err != nil {
@@ -141,15 +199,44 @@ func needsThumbnail(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file) return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
} }
func needsVideoScenes(file services.File) bool {
return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
}
func NeedsThumbnail(file services.File) bool { func NeedsThumbnail(file services.File) bool {
return needsThumbnail(file) return needsThumbnail(file)
} }
func NeedsVideoScenes(file services.File) bool {
return needsVideoScenes(file)
}
func NeedsArchiveListing(file services.File) bool {
return needsArchiveListing(file)
}
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateThumbnail(uploadService, box, file) return generateThumbnail(uploadService, box, file)
} }
func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateVideoScenesThumbnail(uploadService, box, file)
}
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateArchiveListing(uploadService, box, file)
}
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
if services.BoxHasTrouble(box) {
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
}
if file.Processing {
return "", fmt.Errorf("file is still processing")
}
if services.FileHasTrouble(file) {
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
}
thumbnailName := "@thumb@" + file.ID + ".jpg" thumbnailName := "@thumb@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file) object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil { if err != nil {
@@ -184,6 +271,62 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
} }
} }
func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
if !needsVideoScenes(file) {
return "", nil
}
if services.BoxHasTrouble(box) {
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
}
if file.Processing {
return "", fmt.Errorf("file is still processing")
}
if services.FileHasTrouble(file) {
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
}
sceneName := "@scene@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil {
return "", err
}
defer object.Body.Close()
data, err := createVideoScenesThumbnail(file, object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return sceneName, err
}
func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
if !needsArchiveListing(file) {
return "", nil
}
if services.BoxHasTrouble(box) {
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
}
if file.Processing {
return "", fmt.Errorf("file is still processing")
}
if services.FileHasTrouble(file) {
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
}
listingName := "@archive@" + file.ID + ".json"
object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil {
return "", err
}
defer object.Body.Close()
data, err := createArchiveListing(file, object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
return listingName, err
}
func isTextThumbnailCandidate(file services.File) bool { func isTextThumbnailCandidate(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType)) contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 { if i := strings.IndexByte(contentType, ';'); i >= 0 {
@@ -205,6 +348,219 @@ func isTextThumbnailCandidate(file services.File) bool {
} }
} }
func needsArchiveListing(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
switch contentType {
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
return true
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
switch ext {
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
return true
default:
return false
}
}
func archiveListingCurrent(file services.File) bool {
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
}
type archiveTreeNode struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Dir bool `json:"dir"`
Icon string `json:"icon,omitempty"`
Children map[string]*archiveTreeNode `json:"-"`
Items []*archiveTreeNode `json:"items,omitempty"`
}
type archiveListingData struct {
Name string `json:"name"`
Type string `json:"type"`
FileCount int `json:"fileCount"`
FolderCount int `json:"folderCount"`
UncompressedSize uint64 `json:"uncompressedSize"`
Root *archiveTreeNode `json:"root"`
}
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
if err != nil {
return nil, err
}
defer os.Remove(sourceFile.Name())
if _, err := io.Copy(sourceFile, source); err != nil {
sourceFile.Close()
return nil, err
}
if err := sourceFile.Close(); err != nil {
return nil, err
}
archive, err := zip.OpenReader(sourceFile.Name())
if err != nil {
return nil, err
}
defer archive.Close()
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
var totalSize uint64
var fileCount int
var dirCount int
for _, entry := range archive.File {
name := strings.Trim(entry.Name, "/")
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
continue
}
parts := strings.Split(name, "/")
node := root
for i, part := range parts {
if part == "" {
continue
}
if node.Children == nil {
node.Children = map[string]*archiveTreeNode{}
}
child, ok := node.Children[part]
if !ok {
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
node.Children[part] = child
if child.Dir {
dirCount++
}
}
node = child
}
if !entry.FileInfo().IsDir() {
node.Dir = false
node.Size = entry.UncompressedSize64
totalSize += entry.UncompressedSize64
fileCount++
}
}
finalizeArchiveTree(root)
data := archiveListingData{
Name: file.Name,
Type: archiveLabel(file),
FileCount: fileCount,
FolderCount: dirCount,
UncompressedSize: totalSize,
Root: root,
}
return json.MarshalIndent(data, "", " ")
}
func finalizeArchiveTree(node *archiveTreeNode) {
node.Items = sortedArchiveChildren(node)
for _, child := range node.Items {
if child.Dir {
child.Icon = "folder"
finalizeArchiveTree(child)
} else {
child.Icon = archiveFileIconName(child.Name)
}
}
}
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
children := sortedArchiveChildren(node)
for i, child := range children {
last := i == len(children)-1
branch := "|-- "
nextPrefix := prefix + "| "
if last {
branch = "`-- "
nextPrefix = prefix + " "
}
out.WriteString(prefix)
out.WriteString(branch)
out.WriteString(archiveNodeLabel(child))
out.WriteString("\n")
if child.Dir {
writeArchiveTree(out, child, nextPrefix)
}
}
}
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
children := make([]*archiveTreeNode, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, child)
}
sort.Slice(children, func(i, j int) bool {
if children[i].Dir != children[j].Dir {
return children[i].Dir
}
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
})
return children
}
func archiveNodeLabel(node *archiveTreeNode) string {
if node.Dir {
return "[DIR] " + node.Name + "/"
}
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
}
func archiveFileIcon(name string) string {
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
}
func archiveFileIconName(name string) string {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
return "img"
case "mp4", "mov", "webm", "mkv", "avi":
return "vid"
case "mp3", "wav", "flac", "ogg", "m4a":
return "aud"
case "md", "txt", "log", "csv":
return "txt"
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
return "code"
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
return "arc"
default:
return "file"
}
}
func archiveLabel(file services.File) string {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if ext != "" {
return strings.ToUpper(ext) + " archive"
}
if file.ContentType != "" {
return file.ContentType
}
return "ZIP-compatible archive"
}
func formatArchiveBytes(size uint64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div := float64(unit)
value := float64(size) / div
units := []string{"KiB", "MiB", "GiB", "TiB"}
for _, suffix := range units {
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
value /= div
}
return fmt.Sprintf("%.1f PiB", value)
}
func createImageThumbnail(source io.Reader) ([]byte, error) { func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source) img, _, err := image.Decode(source)
if err != nil { if err != nil {
@@ -233,17 +589,320 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
if err := sourceFile.Close(); err != nil { if err := sourceFile.Close(); err != nil {
return nil, err return nil, err
} }
sourcePath := sourceFile.Name()
candidates := []string{"00:00:01", "00:00:03", "00:00:06"}
var fallback []byte
for _, timestamp := range candidates {
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg") targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
if err != nil { if err != nil {
return nil, err return nil, err
} }
targetPath := targetFile.Name() targetPath := targetFile.Name()
targetFile.Close() targetFile.Close()
defer os.Remove(targetPath) if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil { os.Remove(targetPath)
continue
}
data, err := os.ReadFile(targetPath)
os.Remove(targetPath)
if err != nil {
continue
}
if len(fallback) == 0 {
fallback = data
}
if usableVideoFrame(data) {
return data, nil
}
}
scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath)
if err == nil {
img, err := jpeg.Decode(bytes.NewReader(scenes))
if err == nil {
thumb := resizeNearest(img, 360, 240)
var target bytes.Buffer
if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil {
return target.Bytes(), nil
}
}
}
if len(fallback) > 0 {
return fallback, nil
}
return nil, fmt.Errorf("could not extract a usable video thumbnail")
}
func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) {
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
if err != nil {
return nil, err return nil, err
} }
return os.ReadFile(targetPath) defer os.Remove(sourceFile.Name())
if _, err := io.Copy(sourceFile, source); err != nil {
sourceFile.Close()
return nil, err
}
if err := sourceFile.Close(); err != nil {
return nil, err
}
return createVideoScenesThumbnailFromPath(file, sourceFile.Name())
}
func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) {
info := probeVideoInfo(sourcePath, file)
timestamps := videoSceneTimestamps(info.Duration)
frames := make([]videoSceneFrame, 0, len(timestamps))
for _, timestamp := range timestamps {
targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg")
if err != nil {
continue
}
targetPath := targetFile.Name()
targetFile.Close()
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil {
os.Remove(targetPath)
continue
}
data, err := os.ReadFile(targetPath)
os.Remove(targetPath)
if err != nil {
continue
}
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
continue
}
frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img})
}
return renderVideoScenesThumbnail(file, info, frames), nil
}
func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error {
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run()
}
type videoSceneFrame struct {
Timestamp string
Image image.Image
}
type videoInfo struct {
Codec string
Width int
Height int
Duration float64
FrameRate string
}
func probeVideoInfo(sourcePath string, file services.File) videoInfo {
info := videoInfo{Codec: "unknown", FrameRate: "unknown"}
output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output()
if err != nil {
if file.ContentType != "" {
info.Codec = file.ContentType
}
return info
}
for _, line := range strings.Split(string(output), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || value == "" || value == "N/A" {
continue
}
switch key {
case "codec_name":
info.Codec = value
case "width":
info.Width, _ = strconv.Atoi(value)
case "height":
info.Height, _ = strconv.Atoi(value)
case "duration":
info.Duration, _ = strconv.ParseFloat(value, 64)
case "avg_frame_rate":
info.FrameRate = simplifyFrameRate(value)
}
}
return info
}
func simplifyFrameRate(value string) string {
if value == "0/0" || value == "" {
return "unknown"
}
parts := strings.Split(value, "/")
if len(parts) != 2 {
return value
}
n, errN := strconv.ParseFloat(parts[0], 64)
d, errD := strconv.ParseFloat(parts[1], 64)
if errN != nil || errD != nil || d == 0 {
return value
}
return fmt.Sprintf("%.2f fps", n/d)
}
func videoSceneTimestamps(duration float64) []string {
if duration > 4 {
points := []float64{0.12, 0.33, 0.58, 0.82}
timestamps := make([]string, 0, len(points))
for _, point := range points {
seconds := duration * point
if seconds < 1 {
seconds = 1
}
timestamps = append(timestamps, secondsToTimestamp(seconds))
}
return timestamps
}
return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"}
}
func secondsToTimestamp(seconds float64) string {
total := int(seconds + 0.5)
hours := total / 3600
minutes := total % 3600 / 60
secs := total % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs)
}
func usableVideoFrame(data []byte) bool {
img, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
return false
}
return averageLuma(img) >= 18
}
func averageLuma(img image.Image) float64 {
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
if width <= 0 || height <= 0 {
return 0
}
stepX := max(1, width/80)
stepY := max(1, height/80)
var total float64
var samples int
for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY {
for x := bounds.Min.X; x < bounds.Max.X; x += stepX {
r, g, b, _ := img.At(x, y).RGBA()
total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8)
samples++
}
}
if samples == 0 {
return 0
}
return total / float64(samples)
}
func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte {
canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630))
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff})
drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff})
drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff})
drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff})
drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff})
face := basicfont.Face7x13
drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff})
meta := videoMetaLines(file, info)
y := 122
for _, line := range meta {
drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff})
y += 20
}
cells := []image.Rectangle{
image.Rect(62, 212, 586, 388),
image.Rect(614, 212, 1138, 388),
image.Rect(62, 414, 586, 566),
image.Rect(614, 414, 1138, 566),
}
for i, rect := range cells {
drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff})
if i < len(frames) {
drawImageCover(canvas, rect, frames[i].Image)
drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc})
drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
} else {
drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
}
}
var target bytes.Buffer
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86})
return target.Bytes()
}
func videoMetaLines(file services.File, info videoInfo) []string {
resolution := "unknown resolution"
if info.Width > 0 && info.Height > 0 {
resolution = fmt.Sprintf("%dx%d", info.Width, info.Height)
}
duration := "unknown duration"
if info.Duration > 0 {
duration = secondsToHumanDuration(info.Duration)
}
contentType := file.ContentType
if contentType == "" {
contentType = "video"
}
return []string{
"Duration: " + duration + " Codec: " + info.Codec,
"Resolution: " + resolution + " Frame rate: " + info.FrameRate,
"Type: " + contentType + " Generated by Warpbox",
}
}
func secondsToHumanDuration(seconds float64) string {
total := int(seconds + 0.5)
hours := total / 3600
minutes := total % 3600 / 60
secs := total % 60
if hours > 0 {
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
}
return fmt.Sprintf("%d:%02d", minutes, secs)
}
func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) {
bounds := src.Bounds()
srcW := bounds.Dx()
srcH := bounds.Dy()
dstW := rect.Dx()
dstH := rect.Dy()
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
return
}
srcRatio := float64(srcW) / float64(srcH)
dstRatio := float64(dstW) / float64(dstH)
crop := bounds
if srcRatio > dstRatio {
newW := int(float64(srcH) * dstRatio)
x0 := bounds.Min.X + (srcW-newW)/2
crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y)
} else if srcRatio < dstRatio {
newH := int(float64(srcW) / dstRatio)
y0 := bounds.Min.Y + (srcH-newH)/2
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH)
}
for y := rect.Min.Y; y < rect.Max.Y; y++ {
for x := rect.Min.X; x < rect.Max.X; x++ {
u := float64(x-rect.Min.X) / float64(dstW)
v := float64(y-rect.Min.Y) / float64(dstH)
srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx())))
srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy())))
dst.Set(x, y, src.At(srcX, srcY))
}
}
} }
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) { func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {

View File

@@ -1,7 +1,9 @@
package jobs package jobs
import ( import (
"archive/zip"
"bytes" "bytes"
"encoding/json"
"image" "image"
"image/color" "image/color"
"image/jpeg" "image/jpeg"
@@ -48,6 +50,36 @@ 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) { func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
data, err := createTextThumbnail(services.File{ data, err := createTextThumbnail(services.File{
Name: "notes.md", Name: "notes.md",
@@ -72,6 +104,97 @@ func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
} }
} }
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

@@ -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

@@ -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"`
@@ -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 {

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

@@ -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

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

View File

@@ -152,16 +152,16 @@
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs /* 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;
} }
@@ -741,3 +741,283 @@
: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

@@ -56,6 +56,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 +399,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

@@ -54,6 +54,20 @@
margin: 0.45rem 0 0; 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 { .preview-window {
overflow: hidden; overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary)); border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
@@ -260,12 +274,281 @@
height: clamp(18rem, 64vh, 38rem); height: clamp(18rem, 64vh, 38rem);
} }
.video-scenes-preview {
object-fit: contain;
background: color-mix(in srgb, var(--background) 88%, black 12%);
}
.native-audio-preview { .native-audio-preview {
align-self: center; align-self: center;
width: min(42rem, calc(100% - 2rem)); width: min(42rem, calc(100% - 2rem));
height: auto; 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 { .preview-placeholder {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -278,6 +561,7 @@
.preview-placeholder[hidden], .preview-placeholder[hidden],
.default-preview[hidden], .default-preview[hidden],
.native-preview[hidden], .native-preview[hidden],
.archive-browser-preview[hidden],
.large-preview-gate[hidden], .large-preview-gate[hidden],
.code-preview[hidden], .code-preview[hidden],
.render-preview[hidden] { .render-preview[hidden] {
@@ -394,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;
} }
@@ -414,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;
@@ -423,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;
@@ -528,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;
} }
@@ -586,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;
@@ -679,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,

View File

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

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%;

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

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

View File

@@ -14,7 +14,11 @@
const openBox = document.querySelector("#open-box"); const openBox = document.querySelector("#open-box");
const manageLink = document.querySelector("#manage-link"); const manageLink = document.querySelector("#manage-link");
const newUpload = document.querySelector("#new-upload"); const newUpload = document.querySelector("#new-upload");
const folderPicker = document.querySelector("[data-folder-picker]");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
const CELLULAR_WARNING_THRESHOLD_BYTES = 200 * 1024 * 1024;
if (!form || !dropZone || !fileInput) { if (!form || !dropZone || !fileInput) {
return; return;
@@ -47,6 +51,9 @@
let uploadLocked = false; let uploadLocked = false;
let recoveredDraft = null; let recoveredDraft = null;
let resumeMode = false; let resumeMode = false;
let sharedTargetDraft = null;
const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10);
const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit");
["dragenter", "dragover"].forEach((eventName) => { ["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, (event) => { dropZone.addEventListener(eventName, (event) => {
@@ -69,18 +76,18 @@
}); });
document.addEventListener("drop", (event) => { document.addEventListener("drop", (event) => {
if (!event.dataTransfer || !event.dataTransfer.files.length) { if (!hasTransferFiles(event.dataTransfer)) {
return; return;
} }
event.preventDefault(); event.preventDefault();
if (!dropZone.contains(event.target)) { if (!dropZone.contains(event.target)) {
addSelectedFiles(event.dataTransfer.files); addDroppedFiles(event.dataTransfer);
} }
}); });
dropZone.addEventListener("drop", (event) => { dropZone.addEventListener("drop", (event) => {
if (event.dataTransfer && event.dataTransfer.files.length > 0) { if (hasTransferFiles(event.dataTransfer)) {
addSelectedFiles(event.dataTransfer.files); addDroppedFiles(event.dataTransfer);
} }
}); });
@@ -89,15 +96,58 @@
fileInput.value = ""; fileInput.value = "";
}); });
document.addEventListener("paste", (event) => {
if (!event.clipboardData || !event.clipboardData.files || event.clipboardData.files.length === 0) {
return;
}
if (isTextEditingTarget(event.target)) {
return;
}
event.preventDefault();
addSelectedFiles(event.clipboardData.files, { source: "pasted" });
});
if (folderPicker && typeof window.showDirectoryPicker === "function") {
folderPicker.hidden = false;
folderPicker.addEventListener("click", async () => {
if (uploadLocked) {
return;
}
try {
updateStatus("Reading folder...");
const directory = await window.showDirectoryPicker();
const files = await filesFromDirectoryHandle(directory, directory.name || "");
addSelectedFiles(files, { source: "folder" });
} catch (error) {
if (!error || error.name !== "AbortError") {
updateStatus("Folder could not be read.");
}
}
});
}
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
updateStatus("Choose at least one file first."); updateStatus("Choose at least one file first.");
notify("warning", "Choose at least one file first.", {
title: "No files selected",
});
return; return;
} }
if (!validateSelectedFilesWithinLimit(selectedFiles)) {
return;
}
if (isSlowOrMeteredConnection() && totalSelectedBytes(selectedFiles) >= CELLULAR_WARNING_THRESHOLD_BYTES) {
const proceed = await confirmCellularUpload(selectedFiles);
if (!proceed) {
return;
}
}
const submit = form.querySelector("button[type='submit']"); const submit = form.querySelector("button[type='submit']");
const formData = uploadFormData(); const formData = uploadFormData();
await maybeRequestUploadNotificationPermission(selectedFiles);
if (resumeMode && recoveredDraft) { if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles); renderResumeQueue(recoveredDraft.session, selectedFiles);
} else { } else {
@@ -108,8 +158,11 @@
try { try {
const payload = await uploadResumable(form.action, formData, selectedFiles); const payload = await uploadResumable(form.action, formData, selectedFiles);
renderResult(payload); renderResult(payload);
showUploadNotification("Warpbox upload complete", `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} uploaded.`, payload.boxUrl);
await clearSharedTargetPayload();
form.reset(); form.reset();
selectedFiles = []; selectedFiles = [];
sharedTargetDraft = null;
resumeMode = false; resumeMode = false;
recoveredDraft = null; recoveredDraft = null;
fileInput.value = ""; fileInput.value = "";
@@ -123,6 +176,8 @@
} }
} catch (error) { } catch (error) {
updateStatus(error.message || "Upload failed"); updateStatus(error.message || "Upload failed");
notifyUploadError(error);
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
} finally { } finally {
setLoading(false, submit); setLoading(false, submit);
} }
@@ -136,26 +191,388 @@
if (newUpload) { if (newUpload) {
newUpload.addEventListener("click", () => { newUpload.addEventListener("click", () => {
if (sharedTargetDraft) {
clearSharedTargetPayload().finally(() => resetFreshUploadState());
return;
}
cancelRecoveredDraft().catch((error) => { cancelRecoveredDraft().catch((error) => {
updateStatus(error.message || "Upload draft could not be deleted"); updateStatus(error.message || "Upload draft could not be deleted");
}); });
}); });
} }
if (isShareTargetLaunch()) {
loadSharedTargetFiles();
} else {
recoverResumableSessions(); recoverResumableSessions();
}
function addSelectedFiles(files) { function addSelectedFiles(files, options) {
if (uploadLocked) { if (uploadLocked) {
return; return;
} }
const rejected = [];
Array.from(files || []).forEach((file) => { Array.from(files || []).forEach((file) => {
if (fileExceedsUploadLimit(file)) {
rejected.push(file);
return;
}
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) { if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
selectedFiles.push(file); selectedFiles.push(file);
} }
}); });
if (rejected.length > 0) {
notifyRejectedFiles(rejected);
}
if (options && options.source === "pasted" && files && files.length > 0) {
updateStatus(`${files.length} pasted file${files.length === 1 ? "" : "s"} ready.`);
}
if (options && options.source === "folder" && files && files.length > 0) {
updateStatus(`${files.length} folder file${files.length === 1 ? "" : "s"} ready.`);
}
updateSelectedState(); updateSelectedState();
} }
async function addDroppedFiles(dataTransfer) {
if (uploadLocked) {
return;
}
const files = await filesFromDataTransfer(dataTransfer);
addSelectedFiles(files, { source: hasDirectoryItems(dataTransfer) ? "folder" : "dropped" });
}
async function filesFromDataTransfer(dataTransfer) {
const items = Array.from(dataTransfer.items || []);
const entries = items
.map((item) => typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null)
.filter(Boolean);
if (entries.length === 0) {
return Array.from(dataTransfer.files || []);
}
const nested = await Promise.all(entries.map((entry) => filesFromEntry(entry, "")));
return nested.flat();
}
function hasDirectoryItems(dataTransfer) {
return Array.from(dataTransfer.items || []).some((item) => {
const entry = typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null;
return entry && entry.isDirectory;
});
}
function hasTransferFiles(dataTransfer) {
if (!dataTransfer) {
return false;
}
if (dataTransfer.files && dataTransfer.files.length > 0) {
return true;
}
return Array.from(dataTransfer.items || []).some((item) => item.kind === "file");
}
function filesFromEntry(entry, parentPath) {
if (!entry) {
return Promise.resolve([]);
}
const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
if (entry.isFile) {
return new Promise((resolve) => {
entry.file((file) => resolve([withRelativePath(file, relativePath)]), () => resolve([]));
});
}
if (!entry.isDirectory) {
return Promise.resolve([]);
}
const reader = entry.createReader();
const children = [];
return new Promise((resolve) => {
const readBatch = () => {
reader.readEntries(async (entries) => {
if (!entries.length) {
const nested = await Promise.all(children.map((child) => filesFromEntry(child, relativePath)));
resolve(nested.flat());
return;
}
children.push(...entries);
readBatch();
}, () => resolve([]));
};
readBatch();
});
}
async function filesFromDirectoryHandle(directory, parentPath) {
const files = [];
for await (const [name, handle] of directory.entries()) {
const relativePath = parentPath ? `${parentPath}/${name}` : name;
if (handle.kind === "file") {
const file = await handle.getFile();
files.push(withRelativePath(file, relativePath));
} else if (handle.kind === "directory") {
files.push(...await filesFromDirectoryHandle(handle, relativePath));
}
}
return files;
}
function withRelativePath(file, relativePath) {
if (!file || !relativePath) {
return file;
}
try {
Object.defineProperty(file, "warpboxRelativePath", {
value: normalizeRelativePath(relativePath),
configurable: true,
});
} catch (error) {
file.warpboxRelativePath = normalizeRelativePath(relativePath);
}
return file;
}
function normalizeRelativePath(value) {
return String(value || "")
.replace(/\\/g, "/")
.split("/")
.filter((part) => part && part !== "." && part !== "..")
.join("/");
}
function uploadName(file) {
return normalizeRelativePath(file && (file.warpboxRelativePath || file.webkitRelativePath || file.name)) || (file && file.name) || "file";
}
function isTextEditingTarget(target) {
if (!target) {
return false;
}
const tag = (target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || target.isContentEditable;
}
function fileExceedsUploadLimit(file) {
return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes;
}
function validateSelectedFilesWithinLimit(files) {
const rejected = Array.from(files || []).filter(fileExceedsUploadLimit);
if (rejected.length === 0) {
return true;
}
selectedFiles = selectedFiles.filter((file) => !fileExceedsUploadLimit(file));
notifyRejectedFiles(rejected);
updateSelectedState();
return false;
}
function notifyRejectedFiles(files) {
const names = files.slice(0, 3).map((file) => `"${file.name}" (${window.Warpbox.formatBytes(file.size)})`).join(", ");
const extra = files.length > 3 ? `, and ${files.length - 3} more` : "";
const message = `${names}${extra} ${files.length === 1 ? "is" : "are"} over the ${maxUploadLabel} upload limit.`;
updateStatus(message);
notify("error", message, {
title: "Upload limit exceeded",
duration: 9000,
});
}
function notifyUploadError(error) {
const message = error && error.message ? error.message : "Upload failed";
const lower = message.toLowerCase();
const isLimit = lower.includes("limit") || lower.includes("quota") || lower.includes("too large") || lower.includes("exceeds");
notify("error", message, {
title: isLimit ? "Upload limit reached" : "Upload failed",
duration: isLimit ? 9000 : 7200,
});
}
async function maybeRequestUploadNotificationPermission(files) {
if (!("Notification" in window) || Notification.permission !== "default" || totalSelectedBytes(files) < CELLULAR_WARNING_THRESHOLD_BYTES) {
return;
}
try {
await Notification.requestPermission();
} catch (error) {
/* notification permission is optional */
}
}
async function showUploadNotification(title, body, url) {
if (!("Notification" in window) || Notification.permission !== "granted") {
return;
}
if (document.visibilityState === "visible") {
return;
}
const options = {
body,
icon: "/static/android-chrome-192x192.png",
badge: "/static/favicon-32x32.png",
data: { url: window.Warpbox.absoluteURL(url || "/") },
};
try {
const registration = navigator.serviceWorker ? await navigator.serviceWorker.ready : null;
if (registration && registration.showNotification) {
await registration.showNotification(title, options);
return;
}
} catch (error) {
/* fall through to page notification */
}
try {
const notification = new Notification(title, options);
notification.onclick = () => {
window.focus();
if (url) {
window.location.href = window.Warpbox.absoluteURL(url);
}
notification.close();
};
} catch (error) {
/* notifications are best-effort */
}
}
function notify(variant, message, options) {
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
window.Warpbox.notify({ ...(options || {}), variant, message });
}
}
function isSlowOrMeteredConnection() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!connection) {
return false;
}
if (connection.saveData === true) {
return true;
}
return ["slow-2g", "2g", "3g"].includes(connection.effectiveType);
}
function totalSelectedBytes(files) {
return files.reduce((sum, file) => sum + file.size, 0);
}
function confirmCellularUpload(files) {
const list = document.createElement("div");
list.className = "dialog-file-list";
files.forEach((file) => {
const icon = document.createElement("span");
icon.className = "svg-icon svg-icon-document dialog-file-icon";
icon.setAttribute("aria-hidden", "true");
const name = document.createElement("span");
name.className = "dialog-file-name";
name.textContent = file.name;
name.title = file.name;
const size = document.createElement("span");
size.className = "dialog-file-size";
size.textContent = window.Warpbox.formatBytes(file.size);
const row = document.createElement("div");
row.className = "dialog-file-row";
row.append(icon, name, size);
list.append(row);
});
const totalLabel = window.Warpbox.formatBytes(totalSelectedBytes(files));
const message = `You're on a slow or metered connection. You're about to upload ${files.length} file${files.length === 1 ? "" : "s"} (${totalLabel} total) — this could take a while or use up your data plan.`;
return window.Warpbox.confirmDialog(message, {
title: "Slow connection detected",
variant: "warning",
body: list,
confirmLabel: "Upload anyway",
cancelLabel: "Cancel",
});
}
function isShareTargetLaunch() {
const params = new URLSearchParams(window.location.search || "");
return params.has("share-target");
}
async function loadSharedTargetFiles() {
if (!("caches" in window) || typeof File === "undefined") {
updateStatus("Shared files could not be loaded in this browser.");
recoverResumableSessions();
return;
}
updateStatus("Loading shared files...");
try {
const cache = await caches.open(SHARE_CACHE);
const metadataResponse = await cache.match(SHARE_LATEST_KEY);
if (!metadataResponse) {
updateStatus(new URLSearchParams(window.location.search).get("share-target") === "unsupported"
? "Install Warpbox as an app to share files into it from your device."
: "No shared files were found.");
recoverResumableSessions();
return;
}
const metadata = await metadataResponse.json();
if (metadata.error) {
updateStatus(metadata.error);
recoverResumableSessions();
return;
}
const files = [];
for (const item of metadata.files || []) {
if (!item.key) {
continue;
}
const response = await cache.match(item.key);
if (!response) {
continue;
}
const blob = await response.blob();
files.push(new File([blob], item.name || "shared-file", {
type: item.type || blob.type || "application/octet-stream",
lastModified: item.lastModified || Date.now(),
}));
}
sharedTargetDraft = metadata;
selectedFiles = files;
resumeMode = false;
recoveredDraft = null;
validateSelectedFilesWithinLimit(selectedFiles);
if (selectedFiles.length > 0) {
renderQueue(selectedFiles, "queued", { shared: true });
updateStatus("Shared files ready.");
} else {
updateStatus("No files were included in this share.");
}
updateSelectedState();
} catch (error) {
updateStatus(error.message || "Shared files could not be loaded.");
recoverResumableSessions();
}
}
async function clearSharedTargetPayload() {
const draft = sharedTargetDraft;
sharedTargetDraft = null;
if (!draft || !("caches" in window)) {
sharedTargetDraft = null;
return;
}
try {
const cache = await caches.open(SHARE_CACHE);
for (const item of draft.files || []) {
if (item.key) {
await cache.delete(item.key);
}
}
if (draft.id) {
await cache.delete("/__warpbox_share_target__/meta/" + encodeURIComponent(draft.id));
}
await cache.delete(SHARE_LATEST_KEY);
} catch (error) {
/* ignore cache cleanup failures */
}
}
function removeSelectedFile(index) { function removeSelectedFile(index) {
if (uploadLocked) { if (uploadLocked) {
return; return;
@@ -175,12 +592,18 @@
fileSummary.textContent = count === 0 fileSummary.textContent = count === 0
? "Reselect missing files to resume, or add extra files to this upload." ? "Reselect missing files to resume, or add extra files to this upload."
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`; : `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`;
} else if (sharedTargetDraft) {
fileSummary.textContent = count === 0
? "No shared files were received."
: `${count} shared file${count === 1 ? "" : "s"} ready. Review options, then upload.`;
} else { } else {
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`; fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
} }
} }
if (resumeMode && recoveredDraft) { if (resumeMode && recoveredDraft) {
renderResumeQueue(recoveredDraft.session, selectedFiles); renderResumeQueue(recoveredDraft.session, selectedFiles);
} else if (sharedTargetDraft && count > 0) {
renderQueue(selectedFiles, "queued", { shared: true });
} else if (count > 0) { } else if (count > 0) {
renderQueue(selectedFiles, "queued"); renderQueue(selectedFiles, "queued");
} else if (uploadQueue) { } else if (uploadQueue) {
@@ -194,7 +617,7 @@
if (!newUpload) { if (!newUpload) {
return; return;
} }
const visible = Boolean(resumeMode && recoveredDraft); const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft);
newUpload.hidden = !visible; newUpload.hidden = !visible;
newUpload.style.display = visible ? "" : "none"; newUpload.style.display = visible ? "" : "none";
} }
@@ -336,7 +759,7 @@
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file))); const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
const createPayload = { const createPayload = {
files: files.map((file, index) => ({ files: files.map((file, index) => ({
name: file.name, name: uploadName(file),
size: file.size, size: file.size,
contentType: file.type || "application/octet-stream", contentType: file.type || "application/octet-stream",
fingerprint: fingerprints[index], fingerprint: fingerprints[index],
@@ -803,6 +1226,7 @@
selectedFiles = []; selectedFiles = [];
resumeMode = false; resumeMode = false;
recoveredDraft = null; recoveredDraft = null;
sharedTargetDraft = null;
fileInput.value = ""; fileInput.value = "";
result.hidden = true; result.hidden = true;
if (resultList) { if (resultList) {
@@ -862,7 +1286,7 @@
const rows = []; const rows = [];
const localByNameSize = new Map(); const localByNameSize = new Map();
(localFiles || []).forEach((file, index) => { (localFiles || []).forEach((file, index) => {
localByNameSize.set(`${file.name}:${file.size}`, { file, index }); localByNameSize.set(`${uploadName(file)}:${file.size}`, { file, index });
}); });
const usedLocalIndexes = new Set(); const usedLocalIndexes = new Set();
(session.files || []).forEach((file) => { (session.files || []).forEach((file) => {
@@ -873,7 +1297,7 @@
usedLocalIndexes.add(localMatch.index); usedLocalIndexes.add(localMatch.index);
} }
rows.push({ rows.push({
name: file.name, name: uploadName(file),
size: file.size, size: file.size,
uploadedBytes, uploadedBytes,
meta: complete meta: complete
@@ -893,7 +1317,7 @@
return; return;
} }
rows.push({ rows.push({
name: file.name, name: uploadName(file),
meta: `${window.Warpbox.formatBytes(file.size)} · new file`, meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
progress: 0, progress: 0,
status: "queued", status: "queued",
@@ -913,20 +1337,22 @@
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100))); return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
} }
function renderQueue(files, status) { function renderQueue(files, status, options) {
if (!uploadQueue) { if (!uploadQueue) {
return; return;
} }
const shared = Boolean(options && options.shared);
uploadQueue.hidden = files.length === 0; uploadQueue.hidden = files.length === 0;
uploadQueue.replaceChildren(); uploadQueue.replaceChildren();
files.forEach((file, index) => { files.forEach((file, index) => {
uploadQueue.append(createFileRow({ uploadQueue.append(createFileRow({
name: file.name, name: uploadName(file),
meta: window.Warpbox.formatBytes(file.size), meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size),
progress: status === "queued" ? 0 : 100, progress: status === "queued" ? 0 : 100,
status, status,
index, index,
removable: status === "queued", removable: status === "queued",
shared,
})); }));
}); });
} }
@@ -965,6 +1391,12 @@
badge.textContent = "Needs local file"; badge.textContent = "Needs local file";
side.append(badge); side.append(badge);
} }
if (file.shared) {
const badge = document.createElement("small");
badge.className = "upload-file-state upload-file-state-shared";
badge.textContent = "Shared from device";
side.append(badge);
}
if (file.removable) { if (file.removable) {
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "upload-file-remove"; remove.className = "upload-file-remove";
@@ -982,14 +1414,16 @@
function uploadFormData() { function uploadFormData() {
const formData = new FormData(form); const formData = new FormData(form);
formData.delete("file"); formData.delete("file");
formData.delete("file_path");
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
formData.append("file", file, file.name); formData.append("file", file, uploadName(file));
formData.append("file_path", uploadName(file));
}); });
return formData; return formData;
} }
function fileIdentity(file) { function fileIdentity(file) {
return [file.name, file.size, file.lastModified || 0].join(":"); return [uploadName(file), file.size, file.lastModified || 0].join(":");
} }
async function fileFingerprint(file) { async function fileFingerprint(file) {
@@ -998,7 +1432,7 @@
} }
const sampleSize = Math.min(file.size, 1024 * 1024); const sampleSize = Math.min(file.size, 1024 * 1024);
const sample = await file.slice(0, sampleSize).arrayBuffer(); const sample = await file.slice(0, sampleSize).arrayBuffer();
const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":")); const metadata = new TextEncoder().encode([uploadName(file), file.size, file.lastModified || 0, sampleSize].join(":"));
const combined = new Uint8Array(metadata.byteLength + sample.byteLength); const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
combined.set(metadata, 0); combined.set(metadata, 0);
combined.set(new Uint8Array(sample), metadata.byteLength); combined.set(new Uint8Array(sample), metadata.byteLength);

View File

@@ -16,6 +16,8 @@
sourceURL: preview.dataset.sourceUrl || "", sourceURL: preview.dataset.sourceUrl || "",
downloadURL: preview.dataset.downloadUrl || "", downloadURL: preview.dataset.downloadUrl || "",
iconURL: preview.dataset.iconUrl || "", iconURL: preview.dataset.iconUrl || "",
sceneURL: preview.dataset.sceneUrl || "",
archiveURL: preview.dataset.archiveUrl || "",
activeMode: "", activeMode: "",
defaultMode: "default", defaultMode: "default",
pendingMode: "", pendingMode: "",
@@ -24,6 +26,11 @@
rawLoaded: false, rawLoaded: false,
prismLoaded: false, prismLoaded: false,
renderLoaded: false, renderLoaded: false,
sceneLoaded: false,
archiveLoaded: false,
archiveUIRendered: false,
archiveData: null,
archiveText: "",
renderFullscreenFallback: false, renderFullscreenFallback: false,
confirmedLargeModes: {}, confirmedLargeModes: {},
tabs: [] tabs: []
@@ -35,11 +42,15 @@
defaultPane: preview.querySelector("[data-default-preview]"), defaultPane: preview.querySelector("[data-default-preview]"),
imagePane: preview.querySelector("[data-image-preview]"), imagePane: preview.querySelector("[data-image-preview]"),
videoPane: preview.querySelector("[data-video-preview]"), videoPane: preview.querySelector("[data-video-preview]"),
videoScenesPane: preview.querySelector("[data-video-scenes-preview]"),
browserAudioPane: preview.querySelector("[data-browser-audio-preview]"), browserAudioPane: preview.querySelector("[data-browser-audio-preview]"),
rawPane: preview.querySelector("[data-raw-preview]"), rawPane: preview.querySelector("[data-raw-preview]"),
rawOutput: preview.querySelector("[data-raw-output]"), rawOutput: preview.querySelector("[data-raw-output]"),
codePane: preview.querySelector("[data-code-preview]"), codePane: preview.querySelector("[data-code-preview]"),
codeOutput: preview.querySelector("[data-code-output]"), codeOutput: preview.querySelector("[data-code-output]"),
archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"),
archivePane: preview.querySelector("[data-archive-preview]"),
archiveOutput: preview.querySelector("[data-archive-output]"),
renderPane: preview.querySelector("[data-render-preview]"), renderPane: preview.querySelector("[data-render-preview]"),
fullscreenButton: preview.querySelector("[data-render-fullscreen]"), fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
gatePane: preview.querySelector("[data-large-preview-gate]"), gatePane: preview.querySelector("[data-large-preview-gate]"),
@@ -55,6 +66,7 @@
bindLargeGate(); bindLargeGate();
bindThemeChanges(); bindThemeChanges();
bindRenderFullscreen(); bindRenderFullscreen();
configureMediaSession();
renderTabs(); renderTabs();
selectMode(state.defaultMode); selectMode(state.defaultMode);
@@ -65,6 +77,7 @@
var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml"; var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0; var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0; var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
return { return {
extension: extension, extension: extension,
@@ -76,6 +89,7 @@
isImage: isImage, isImage: isImage,
isVideo: isVideo, isVideo: isVideo,
isAudio: isAudio, isAudio: isAudio,
isArchive: isArchive,
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
}; };
} }
@@ -90,6 +104,9 @@
if (type.isVideo) { if (type.isVideo) {
tabs.push({ mode: "video", label: "Video Preview" }); tabs.push({ mode: "video", label: "Video Preview" });
if (state.sceneURL && els.videoScenesPane) {
tabs.push({ mode: "scenes", label: "Scenes Preview" });
}
return tabs; return tabs;
} }
@@ -98,6 +115,12 @@
return tabs; return tabs;
} }
if (type.isArchive) {
tabs.push({ mode: "archive-ui", label: "Archive Preview" });
tabs.push({ mode: "archive", label: "Text Tree" });
return tabs;
}
if (type.isTextLike) { if (type.isTextLike) {
if (type.isHTML || type.isMarkdown) { if (type.isHTML || type.isMarkdown) {
tabs.push({ mode: "render", label: "Render Preview" }); tabs.push({ mode: "render", label: "Render Preview" });
@@ -116,6 +139,9 @@
if (type.isVideo) { if (type.isVideo) {
return "video"; return "video";
} }
if (type.isArchive) {
return "archive-ui";
}
if (state.sizeBytes > LARGE_PREVIEW_BYTES) { if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
if (type.isAudio && hasMode(tabs, "browser-audio")) { if (type.isAudio && hasMode(tabs, "browser-audio")) {
return "browser-audio"; return "browser-audio";
@@ -181,6 +207,9 @@
show(els.imagePane); show(els.imagePane);
} else if (mode === "video") { } else if (mode === "video") {
show(els.videoPane); show(els.videoPane);
} else if (mode === "scenes") {
show(els.videoScenesPane);
ensureScenesPreview();
} else if (mode === "browser-audio") { } else if (mode === "browser-audio") {
show(els.browserAudioPane); show(els.browserAudioPane);
} else if (mode === "raw") { } else if (mode === "raw") {
@@ -189,6 +218,12 @@
} else if (mode === "code") { } else if (mode === "code") {
show(els.codePane); show(els.codePane);
ensurePrismPreview(); ensurePrismPreview();
} else if (mode === "archive-ui") {
show(els.archiveBrowserPane);
ensureArchiveBrowserPreview();
} else if (mode === "archive") {
show(els.archivePane);
ensureArchivePreview();
} else if (mode === "render") { } else if (mode === "render") {
show(els.renderPane); show(els.renderPane);
if (fileType.isMarkdown) { if (fileType.isMarkdown) {
@@ -267,6 +302,32 @@
document.addEventListener("fullscreenchange", updateRenderFullscreenButton); document.addEventListener("fullscreenchange", updateRenderFullscreenButton);
} }
function configureMediaSession() {
if (!("mediaSession" in navigator) || typeof window.MediaMetadata !== "function") {
return;
}
if (!fileType.isAudio && !fileType.isVideo) {
return;
}
var artworkURL = "";
if (fileType.isVideo && els.videoPane) {
artworkURL = els.videoPane.getAttribute("poster") || state.iconURL || "";
} else {
artworkURL = state.iconURL || "";
}
var metadata = {
title: state.fileName || "Warpbox media",
artist: "Warpbox",
album: state.sizeLabel || state.contentType || ""
};
if (artworkURL) {
metadata.artwork = [
{ src: window.Warpbox.absoluteURL(artworkURL), sizes: "512x512", type: "image/png" }
];
}
navigator.mediaSession.metadata = new MediaMetadata(metadata);
}
function ensureTextLoaded() { function ensureTextLoaded() {
if (state.textLoaded) { if (state.textLoaded) {
return Promise.resolve(state.textSource); return Promise.resolve(state.textSource);
@@ -403,9 +464,12 @@
hide(els.defaultPane); hide(els.defaultPane);
hide(els.imagePane); hide(els.imagePane);
hide(els.videoPane); hide(els.videoPane);
hide(els.videoScenesPane);
hide(els.browserAudioPane); hide(els.browserAudioPane);
hide(els.rawPane); hide(els.rawPane);
hide(els.codePane); hide(els.codePane);
hide(els.archiveBrowserPane);
hide(els.archivePane);
hide(els.renderPane); hide(els.renderPane);
hide(els.gatePane); hide(els.gatePane);
hide(els.placeholder); hide(els.placeholder);
@@ -498,14 +562,275 @@
"default": "Default", "default": "Default",
"image": "Image preview", "image": "Image preview",
"video": "Video preview", "video": "Video preview",
"scenes": "Scenes preview",
"browser-audio": "Browser preview", "browser-audio": "Browser preview",
"raw": "Raw preview", "raw": "Raw preview",
"code": "Code preview", "code": "Code preview",
"archive-ui": "Archive preview",
"archive": "Archive preview",
"render": "Render preview" "render": "Render preview"
}; };
return labels[mode] || "Preview"; return labels[mode] || "Preview";
} }
function ensureScenesPreview() {
if (state.sceneLoaded || !els.videoScenesPane) {
return;
}
var src = els.videoScenesPane.dataset.sceneSrc || state.sceneURL;
if (!src) {
return;
}
els.videoScenesPane.src = src;
state.sceneLoaded = true;
}
function ensureArchivePreview() {
if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) {
return;
}
ensureArchiveData()
.then(function () {
var text = state.archiveText || archiveDataToText(state.archiveData);
els.archiveOutput.textContent = text;
state.archiveLoaded = true;
hide(els.placeholder);
show(els.archivePane);
})
.catch(function () {
showError("Archive preview could not be loaded.");
});
}
function ensureArchiveBrowserPreview() {
if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) {
return;
}
ensureArchiveData()
.then(function () {
renderArchiveBrowser(state.archiveData);
state.archiveUIRendered = true;
hide(els.placeholder);
show(els.archiveBrowserPane);
})
.catch(function () {
showError("Archive preview could not be loaded.");
});
}
function ensureArchiveData() {
if (state.archiveData || state.archiveText) {
return Promise.resolve();
}
showLoading("Loading archive contents...");
return fetch(state.archiveURL, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("Archive preview could not be loaded.");
}
return response.text();
})
.then(function (text) {
try {
state.archiveData = JSON.parse(text);
} catch (error) {
state.archiveText = text;
}
});
}
function renderArchiveBrowser(data) {
if (!els.archiveBrowserPane) {
return;
}
els.archiveBrowserPane.innerHTML = "";
if (!data || !data.root) {
var fallback = document.createElement("pre");
fallback.className = "archive-browser-legacy";
fallback.textContent = state.archiveText || "Archive preview is unavailable.";
els.archiveBrowserPane.appendChild(fallback);
return;
}
var header = document.createElement("div");
header.className = "archive-browser-header";
header.innerHTML = "<strong></strong><span></span>";
header.querySelector("strong").textContent = data.name || state.fileName || "Archive";
header.querySelector("span").textContent = [
data.type || "Archive",
formatArchiveCount(data.fileCount, "file"),
formatArchiveCount(data.folderCount, "folder"),
formatBytes(data.uncompressedSize || 0)
].filter(Boolean).join(" · ");
els.archiveBrowserPane.appendChild(header);
var tree = document.createElement("div");
tree.className = "archive-tree";
var items = data.root.items || [];
if (items.length === 0) {
var emptyTree = document.createElement("p");
emptyTree.className = "archive-browser-empty";
emptyTree.textContent = "This archive is empty.";
tree.appendChild(emptyTree);
} else {
items.forEach(function (item) {
tree.appendChild(renderArchiveNode(item, 0));
});
}
els.archiveBrowserPane.appendChild(tree);
}
function renderArchiveNode(node, depth) {
var row = document.createElement(node.dir ? "details" : "div");
row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file";
if (node.dir && depth < 1) {
row.open = true;
}
var summary = document.createElement(node.dir ? "summary" : "div");
summary.className = "archive-node-row";
summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem";
if (node.dir) {
summary.appendChild(createArchiveChevron());
} else {
var spacer = document.createElement("span");
spacer.className = "archive-chevron-spacer";
summary.appendChild(spacer);
}
summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file")));
var name = document.createElement("span");
name.className = "archive-node-name";
name.textContent = node.name + (node.dir ? "/" : "");
summary.appendChild(name);
if (!node.dir) {
var size = document.createElement("span");
size.className = "archive-node-size";
size.textContent = formatBytes(node.size || 0);
summary.appendChild(size);
}
row.appendChild(summary);
if (node.dir) {
(node.items || []).forEach(function (child) {
row.appendChild(renderArchiveNode(child, depth + 1));
});
}
return row;
}
function createArchiveChevron() {
var chevron = document.createElement("span");
chevron.className = "archive-chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.innerHTML = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
return chevron;
}
function createArchiveIcon(icon) {
var element = document.createElement("span");
element.className = "archive-file-icon archive-file-icon-" + icon;
element.setAttribute("aria-hidden", "true");
element.innerHTML = '<span class="archive-svg-icon">' + archiveIconSVG(icon) + '</span>';
var retroURL = archiveRetroIconURL(icon);
if (retroURL) {
var retro = document.createElement("img");
retro.className = "archive-retro-icon";
retro.src = retroURL;
retro.alt = "";
retro.decoding = "async";
retro.loading = "lazy";
element.appendChild(retro);
}
return element;
}
function archiveDataToText(data) {
if (!data || !data.root) {
return state.archiveText || "";
}
var lines = [
"Archive preview",
"Name: " + (data.name || state.fileName || "Archive"),
"Type: " + (data.type || "Archive"),
"Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders",
"Uncompressed size: " + formatBytes(data.uncompressedSize || 0),
"",
"."
];
appendArchiveTextLines(lines, data.root.items || [], "");
if (!(data.root.items || []).length) {
lines.push("(empty archive)");
}
return lines.join("\n");
}
function appendArchiveTextLines(lines, items, prefix) {
items.forEach(function (item, index) {
var last = index === items.length - 1;
var branch = last ? "`-- " : "|-- ";
var nextPrefix = prefix + (last ? " " : "| ");
var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")";
lines.push(prefix + branch + label);
if (item.dir) {
appendArchiveTextLines(lines, item.items || [], nextPrefix);
}
});
}
function archiveIconSVG(icon) {
var icons = {
folder: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
};
return icons[icon] || icons.file;
}
function archiveRetroIconURL(icon) {
var base = "/static/file-icons/retro/";
var icons = {
folder: "directory_open_file_mydocs-4.png",
img: "shimgvw.dll_14_1-2.png",
vid: "wmploc.dll_14_504-2.png",
aud: "wmploc.dll_14_610-2.png",
txt: "shell32.dll_14_151-2.png",
code: "mshtml.dll_14_2660-2.png",
arc: "zipfldr.dll_14_101-2.png",
file: "shell32.dll_14_152-2.png"
};
return base + (icons[icon] || icons.file);
}
function formatArchiveCount(value, label) {
value = Number(value || 0);
return value + " " + label + (value === 1 ? "" : "s");
}
function formatBytes(value) {
value = Number(value || 0);
if (value < 1024) {
return value + " B";
}
var units = ["KiB", "MiB", "GiB", "TiB"];
var size = value / 1024;
for (var i = 0; i < units.length; i++) {
if (size < 1024 || i === units.length - 1) {
return size.toFixed(1) + " " + units[i];
}
size /= 1024;
}
return value + " B";
}
function loadPrism() { function loadPrism() {
if (window.Prism) { if (window.Prism) {
return Promise.resolve(); return Promise.resolve();
@@ -632,6 +957,28 @@
return parts.length > 1 ? parts.pop() : ""; return parts.length > 1 ? parts.pop() : "";
} }
function isArchiveFile(extension, baseType) {
var archiveExtensions = {
"apk": true,
"docx": true,
"ear": true,
"epub": true,
"jar": true,
"pptx": true,
"war": true,
"xlsx": true,
"zip": true
};
var archiveTypes = {
"application/epub+zip": true,
"application/java-archive": true,
"application/vnd.android.package-archive": true,
"application/x-zip-compressed": true,
"application/zip": true
};
return Boolean(archiveExtensions[extension] || archiveTypes[baseType]);
}
function languageFor(extension, baseType) { function languageFor(extension, baseType) {
var extensionMap = { var extensionMap = {
"c": "c", "c": "c",

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

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,7 +4,7 @@
<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}}">
@@ -54,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}}">
@@ -67,14 +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/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

@@ -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,176 @@ 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>
<figure class="code-block">
<pre><code># 1. Create a session.
curl -s {{.Data.BaseURL}}/api/v1/uploads/resumable \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
-d '{"files":[{"name":"report.pdf","size":1048576,"contentType":"application/pdf"}],"expiresMinutes":1440}'
<h3>1 · Import the uploader</h3> # 2. Upload each chunk using the returned sessionId, file id, and chunkSize.
dd if=./report.pdf bs=8388608 count=1 skip=0 2>/dev/null | \
curl -X PUT --data-binary @- \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
# 3. Complete after all chunks are present. The response is the normal upload JSON.
curl -X POST -H 'Accept: application/json' \
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/complete</code></pre>
</figure>
<p class="muted-copy">Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
</div>
</article>
<article id="ep-meta" class="endpoint card">
<div class="card-content">
<h3>Health &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 +332,183 @@ 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.</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

@@ -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,6 +76,8 @@
<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-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button>
<button class="button button-outline folder-picker-button" type="button" data-folder-picker hidden>Choose folder</button>
<button class="button button-primary" type="submit">Upload files</button> <button class="button button-primary" type="submit">Upload files</button>
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button> <button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
</div> </div>

View File

@@ -23,7 +23,7 @@
</a> </a>
</header> </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}}"> <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 class="preview-window-titlebar">
<div> <div>
<strong data-preview-mode-label>Preview</strong> <strong data-preview-mode-label>Preview</strong>
@@ -49,6 +49,7 @@
</div> </div>
<img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden> <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> <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> <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> <div class="code-preview raw-code-preview" data-raw-preview hidden>
<pre><code data-raw-output></code></pre> <pre><code data-raw-output></code></pre>
@@ -56,6 +57,10 @@
<div class="code-preview prism-code-preview" data-code-preview hidden> <div class="code-preview prism-code-preview" data-code-preview hidden>
<pre class="line-numbers"><code data-code-output></code></pre> <pre class="line-numbers"><code data-code-output></code></pre>
</div> </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> <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> <div class="large-preview-gate" data-large-preview-gate hidden>
<strong>Large preview</strong> <strong>Large preview</strong>