4 Commits

Author SHA1 Message Date
78b767a4a2 feat(upload): add pause and cancel controls for active uploads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m3s
- Add CSS grid layout for upload-active-actions and hidden state
- Implement JavaScript logic for pausing and cancelling uploads with confirmation
- Add test to verify home page includes upload control elements
2026-06-16 01:17:32 +03:00
dc4aee8ca2 fix: stage zip downloads to temp file and improve file serving headers
Write zip to a temporary file before serving to enable correct content-length, range requests, and proper cache-control headers. Additionally, handle negative object sizes by falling back to file metadata for content-length.
2026-06-15 21:52:33 +03:00
e2cf7115b7 style(ui): add color-coded accents and tags to shortcut cards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m44s
Introduce per-card accent colors to enhance the visual hierarchy of the documentation shortcut cards. This includes adding left borders, colored eyebrows, and matching hover glows using CSS variables.

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

Changes include:
- Styling the API sidebar as a raised silver window with a classic title bar.
- Styling endpoint cards as silver windows with navy title bars.
- Excluding API navigation links, shortcut cards, and link pills from default retro link styles to prevent styling conflicts.
- Updating API documentation content, including adding a section for resumable uploads.
2026-06-11 09:19:06 +03:00
17 changed files with 1917 additions and 141 deletions

View File

@@ -626,6 +626,7 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
defer object.Body.Close() defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType) w.Header().Set("Content-Type", file.ContentType)
w.Header().Set("Cache-Control", "no-transform")
disposition := "inline" disposition := "inline"
if attachment { if attachment {
disposition = "attachment" disposition = "attachment"
@@ -634,8 +635,12 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
if seeker, ok := object.Body.(io.ReadSeeker); ok { if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker) http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else { } else {
if object.Size > 0 { size := object.Size
w.Header().Set("Content-Length", fmt.Sprintf("%d", object.Size)) if size < 0 {
size = file.Size
}
if size >= 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, object.Body) _, _ = io.Copy(w, object.Body)
@@ -722,14 +727,44 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "application/zip") tempDir := filepath.Join(a.cfg.DataDir, "tmp", "downloads")
w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip")) if err := os.MkdirAll(tempDir, 0o700); err != nil {
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat)) a.logger.Error("zip staging directory creation failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
if err := a.uploadService.WriteZip(w, box); err != nil {
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
return return
} }
archive, err := os.CreateTemp(tempDir, "warpbox-*.zip")
if err != nil {
a.logger.Error("zip staging file creation failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
archivePath := archive.Name()
defer func() {
archive.Close()
if err := os.Remove(archivePath); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to remove staged zip", "source", "download", "severity", "warn", "box_id", box.ID, "error", err.Error())
}
}()
if err := a.uploadService.WriteZip(archive, box); err != nil {
a.logger.Error("zip download failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
stat, err := archive.Stat()
if err != nil {
a.logger.Error("staged zip stat failed", "source", "download", "severity", "error", "code", 5002, "box_id", box.ID, "error", err.Error())
http.Error(w, "failed to prepare zip download", http.StatusInternalServerError)
return
}
name := "warpbox-" + box.ID + ".zip"
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Cache-Control", "no-transform")
w.Header().Set("Content-Disposition", contentDisposition("attachment", name))
http.ServeContent(w, r, name, stat.ModTime(), archive)
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error()) a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
} }

View File

@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -29,6 +30,30 @@ func TestSetStaticCacheHeaders(t *testing.T) {
} }
} }
func TestHomeIncludesActiveUploadControls(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
app.Home(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
for _, want := range []string{
`id="upload-active-actions"`,
`id="cancel-upload"`,
`id="pause-upload"`,
`Cancel Upload`,
`Pause Upload`,
} {
if !strings.Contains(response.Body.String(), want) {
t.Fatalf("home page missing %q", want)
}
}
}
func TestWebManifestIncludesShareTarget(t *testing.T) { func TestWebManifestIncludesShareTarget(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest")) data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest"))
if err != nil { if err != nil {

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"archive/zip"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
@@ -284,6 +285,40 @@ func TestFileDownloadUsesOriginalFilename(t *testing.T) {
if response.Body.String() != "hello" { if response.Body.String() != "hello" {
t.Fatalf("body = %q", response.Body.String()) t.Fatalf("body = %q", response.Body.String())
} }
if got := response.Header().Get("Content-Length"); got != "5" {
t.Fatalf("Content-Length = %q, want 5", got)
}
if got := response.Header().Get("Cache-Control"); got != "no-transform" {
t.Fatalf("Cache-Control = %q, want no-transform", got)
}
}
func TestZipDownloadIncludesExactContentLength(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
payload := uploadNamedFileThroughApp(t, app, "report.txt", "hello zip")
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/zip", nil)
request.SetPathValue("boxID", payload.BoxID)
response := httptest.NewRecorder()
app.DownloadZip(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
if got, want := response.Header().Get("Content-Length"), strconv.Itoa(response.Body.Len()); got != want {
t.Fatalf("Content-Length = %q, want %s", got, want)
}
if got := response.Header().Get("Cache-Control"); got != "no-transform" {
t.Fatalf("Cache-Control = %q, want no-transform", got)
}
archive, err := zip.NewReader(bytes.NewReader(response.Body.Bytes()), int64(response.Body.Len()))
if err != nil {
t.Fatalf("zip.NewReader returned error: %v", err)
}
if len(archive.File) != 1 || archive.File[0].Name != "report.txt" {
t.Fatalf("unexpected zip files: %+v", archive.File)
}
} }
func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) { func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {

View File

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

View File

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

View File

@@ -1028,9 +1028,13 @@ func (s *UploadService) RecordDownload(boxID string) error {
}) })
} }
func (s *UploadService) WriteZip(w io.Writer, box Box) error { func (s *UploadService) WriteZip(w io.Writer, box Box) (err error) {
archive := zip.NewWriter(w) archive := zip.NewWriter(w)
defer archive.Close() defer func() {
if closeErr := archive.Close(); err == nil {
err = closeErr
}
}()
for _, file := range box.Files { for _, file := range box.Files {
object, err := s.OpenFileObject(context.Background(), box, file) object, err := s.OpenFileObject(context.Background(), box, file)

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

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

@@ -48,6 +48,16 @@
width: 100%; width: 100%;
} }
.upload-active-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.upload-active-actions[hidden] {
display: none !important;
}
.upload-options .form-footer .upload-new-button { .upload-options .form-footer .upload-new-button {
margin-top: -0.25rem; margin-top: -0.25rem;
} }

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

View File

@@ -15,6 +15,11 @@
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 folderPicker = document.querySelector("[data-folder-picker]");
const submitButton = form && form.querySelector("button[type='submit']");
const idleUploadActions = form ? Array.from(form.querySelectorAll(".upload-idle-action")) : [];
const activeUploadActions = document.querySelector("#upload-active-actions");
const cancelUpload = document.querySelector("#cancel-upload");
const pauseUpload = document.querySelector("#pause-upload");
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions"; const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
const SHARE_CACHE = "warpbox-share-target-v1"; const SHARE_CACHE = "warpbox-share-target-v1";
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest"; const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
@@ -52,6 +57,12 @@
let recoveredDraft = null; let recoveredDraft = null;
let resumeMode = false; let resumeMode = false;
let sharedTargetDraft = null; let sharedTargetDraft = null;
let activeUploadRequest = null;
let activeUploadSession = null;
let uploadPaused = false;
let uploadCancelled = false;
let uploadFinalizing = false;
let pauseWaiters = [];
const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10); 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"); const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit");
@@ -145,7 +156,6 @@
} }
} }
const submit = form.querySelector("button[type='submit']");
const formData = uploadFormData(); const formData = uploadFormData();
await maybeRequestUploadNotificationPermission(selectedFiles); await maybeRequestUploadNotificationPermission(selectedFiles);
if (resumeMode && recoveredDraft) { if (resumeMode && recoveredDraft) {
@@ -153,7 +163,7 @@
} else { } else {
renderQueue(selectedFiles, "queued"); renderQueue(selectedFiles, "queued");
} }
setLoading(true, submit); beginUpload();
try { try {
const payload = await uploadResumable(form.action, formData, selectedFiles); const payload = await uploadResumable(form.action, formData, selectedFiles);
@@ -175,14 +185,55 @@
fileSummary.textContent = "Upload complete."; fileSummary.textContent = "Upload complete.";
} }
} catch (error) { } catch (error) {
if (isUploadCancelledError(error)) {
await discardActiveUploadSession();
await clearSharedTargetPayload();
form.reset();
resetFreshUploadState();
return;
}
updateStatus(error.message || "Upload failed"); updateStatus(error.message || "Upload failed");
notifyUploadError(error); notifyUploadError(error);
showUploadNotification("Warpbox upload failed", error.message || "Upload failed"); showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
} finally { } finally {
setLoading(false, submit); finishUpload();
} }
}); });
if (pauseUpload) {
pauseUpload.addEventListener("click", () => {
if (!uploadLocked || uploadCancelled) {
return;
}
if (uploadPaused) {
resumeActiveUpload();
} else {
pauseActiveUpload();
}
});
}
if (cancelUpload) {
cancelUpload.addEventListener("click", async () => {
if (!uploadLocked || uploadCancelled) {
return;
}
const confirmed = await window.Warpbox.confirmDialog(
"Cancel this upload? Uploaded chunks will be deleted and the form will be reset for a new upload.",
{
title: "Cancel upload?",
variant: "warning",
confirmLabel: "Cancel Upload",
cancelLabel: "Keep Uploading",
},
);
if (!confirmed || !uploadLocked || uploadFinalizing) {
return;
}
requestUploadCancellation();
});
}
if (copyURL) { if (copyURL) {
copyURL.addEventListener("click", () => { copyURL.addEventListener("click", () => {
window.Warpbox.copyText(latestBoxURL, copyURL, "Copied"); window.Warpbox.copyText(latestBoxURL, copyURL, "Copied");
@@ -622,20 +673,147 @@
newUpload.style.display = visible ? "" : "none"; newUpload.style.display = visible ? "" : "none";
} }
function setLoading(isLoading, submit) { function beginUpload() {
uploadPaused = false;
uploadCancelled = false;
uploadFinalizing = false;
activeUploadRequest = null;
activeUploadSession = null;
setLoading(true);
}
function finishUpload() {
activeUploadRequest = null;
activeUploadSession = null;
uploadPaused = false;
uploadCancelled = false;
uploadFinalizing = false;
releasePauseWaiters();
setLoading(false);
}
function setLoading(isLoading) {
uploadLocked = isLoading; uploadLocked = isLoading;
if (progress) { if (progress) {
progress.hidden = !isLoading; progress.hidden = !isLoading;
} }
if (submit) { idleUploadActions.forEach((button) => {
submit.disabled = isLoading; button.style.display = isLoading ? "none" : "";
submit.textContent = isLoading ? "Uploading..." : "Upload files"; });
if (activeUploadActions) {
activeUploadActions.hidden = !isLoading;
}
if (submitButton) {
submitButton.disabled = isLoading;
submitButton.textContent = "Upload files";
} }
if (newUpload) { if (newUpload) {
newUpload.disabled = isLoading; newUpload.disabled = isLoading;
} }
if (cancelUpload) {
cancelUpload.disabled = !isLoading;
cancelUpload.textContent = "Cancel Upload";
}
updatePauseButton();
updateStatus(isLoading ? "Transferring files..." : ""); updateStatus(isLoading ? "Transferring files..." : "");
setTotalProgress(isLoading ? 0 : 100); setTotalProgress(isLoading ? 0 : 100);
if (!isLoading) {
updateNewUploadVisibility();
}
}
function pauseActiveUpload() {
uploadPaused = true;
updatePauseButton();
updateStatus("Upload paused.");
if (activeUploadRequest) {
activeUploadRequest.abort();
}
}
function resumeActiveUpload() {
uploadPaused = false;
updatePauseButton();
updateStatus("Resuming upload...");
releasePauseWaiters();
}
function requestUploadCancellation() {
if (uploadFinalizing) {
return;
}
uploadCancelled = true;
uploadPaused = false;
updateStatus("Cancelling upload...");
if (cancelUpload) {
cancelUpload.disabled = true;
cancelUpload.textContent = "Cancelling...";
}
if (pauseUpload) {
pauseUpload.disabled = true;
}
releasePauseWaiters();
if (activeUploadRequest) {
activeUploadRequest.abort();
}
}
function updatePauseButton() {
if (!pauseUpload) {
return;
}
pauseUpload.disabled = !uploadLocked || uploadCancelled || uploadFinalizing;
pauseUpload.textContent = uploadPaused ? "Resume Upload" : "Pause Upload";
pauseUpload.classList.toggle("button-primary", uploadPaused);
pauseUpload.classList.toggle("button-outline", !uploadPaused);
}
function releasePauseWaiters() {
const waiters = pauseWaiters;
pauseWaiters = [];
waiters.forEach((resolve) => resolve());
}
async function waitForUploadReady() {
while (uploadPaused && !uploadCancelled) {
await new Promise((resolve) => pauseWaiters.push(resolve));
}
if (uploadCancelled) {
throw uploadControlError("UploadCancelledError", "Upload cancelled");
}
}
function uploadControlError(name, message) {
const error = new Error(message);
error.name = name;
return error;
}
function isUploadPausedError(error) {
return Boolean(error && error.name === "UploadPausedError");
}
function isUploadCancelledError(error) {
return Boolean(error && error.name === "UploadCancelledError");
}
async function discardActiveUploadSession() {
const session = activeUploadSession;
activeUploadSession = null;
if (!session || !session.sessionId) {
return;
}
await cancelResumableSession(session.sessionId, session.resumeToken).catch(() => {});
removeResumableSession(session.sessionId);
}
function beginUploadFinalization() {
uploadFinalizing = true;
updateStatus("Finalizing upload...");
if (cancelUpload) {
cancelUpload.disabled = true;
}
updatePauseButton();
} }
function updateStatus(message) { function updateStatus(message) {
@@ -709,8 +887,20 @@
} }
function uploadWithProgress(url, formData, files) { function uploadWithProgress(url, formData, files) {
return uploadWithProgressAttempt(url, formData, files).catch(async (error) => {
if (isUploadPausedError(error)) {
await waitForUploadReady();
return uploadWithProgress(url, formData, files);
}
throw error;
});
}
async function uploadWithProgressAttempt(url, formData, files) {
await waitForUploadReady();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = new XMLHttpRequest(); const request = new XMLHttpRequest();
activeUploadRequest = request;
const rateTracker = createTransferRateTracker(0); const rateTracker = createTransferRateTracker(0);
request.open("POST", url); request.open("POST", url);
request.setRequestHeader("Accept", "application/json"); request.setRequestHeader("Accept", "application/json");
@@ -728,6 +918,9 @@
}); });
request.addEventListener("load", () => { request.addEventListener("load", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
let payload = {}; let payload = {};
try { try {
payload = JSON.parse(request.responseText || "{}"); payload = JSON.parse(request.responseText || "{}");
@@ -744,19 +937,37 @@
resolve(payload); resolve(payload);
}); });
request.addEventListener("error", () => reject(new Error("Network error during upload"))); request.addEventListener("error", () => {
request.addEventListener("abort", () => reject(new Error("Upload aborted"))); if (activeUploadRequest === request) {
activeUploadRequest = null;
}
reject(new Error("Network error during upload"));
});
request.addEventListener("abort", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
if (uploadCancelled) {
reject(uploadControlError("UploadCancelledError", "Upload cancelled"));
} else if (uploadPaused) {
reject(uploadControlError("UploadPausedError", "Upload paused"));
} else {
reject(new Error("Upload aborted"));
}
});
request.send(formData); request.send(formData);
}); });
} }
async function uploadResumable(fallbackUrl, formData, files) { async function uploadResumable(fallbackUrl, formData, files) {
await waitForUploadReady();
if (!window.fetch || typeof Blob === "undefined") { if (!window.fetch || typeof Blob === "undefined") {
return uploadWithProgress(fallbackUrl, formData, files); return uploadWithProgress(fallbackUrl, formData, files);
} }
updateStatus("Fingerprinting files..."); updateStatus("Fingerprinting files...");
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file))); const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
await waitForUploadReady();
const createPayload = { const createPayload = {
files: files.map((file, index) => ({ files: files.map((file, index) => ({
name: uploadName(file), name: uploadName(file),
@@ -779,6 +990,7 @@
session = await findResumableSession(createPayload); session = await findResumableSession(createPayload);
} }
if (session) { if (session) {
activeUploadSession = session;
validateResumeSelection(session, createPayload); validateResumeSelection(session, createPayload);
session = await addMissingResumableFiles(session, createPayload); session = await addMissingResumableFiles(session, createPayload);
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) { if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
@@ -787,6 +999,7 @@
if (persistable) { if (persistable) {
saveResumableSession(session, createPayload); saveResumableSession(session, createPayload);
} }
activeUploadSession = session;
} }
if (!session || session.status !== "uploading") { if (!session || session.status !== "uploading") {
try { try {
@@ -800,7 +1013,9 @@
if (persistable) { if (persistable) {
saveResumableSession(session, createPayload); saveResumableSession(session, createPayload);
} }
activeUploadSession = session;
} }
await waitForUploadReady();
const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index])); const sessionFiles = files.map((file, index) => matchSessionFile(session, createPayload.files[index]));
if (sessionFiles.some((file) => !file)) { if (sessionFiles.some((file) => !file)) {
throw new Error("Upload session could not match the selected files"); throw new Error("Upload session could not match the selected files");
@@ -822,6 +1037,7 @@
const sessionFile = sessionFiles[fileIndex]; const sessionFile = sessionFiles[fileIndex];
const uploaded = new Set(sessionFile.uploadedChunks || []); const uploaded = new Set(sessionFile.uploadedChunks || []);
for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < sessionFile.chunkCount; chunkIndex++) {
await waitForUploadReady();
if (uploaded.has(chunkIndex)) { if (uploaded.has(chunkIndex)) {
continue; continue;
} }
@@ -845,12 +1061,14 @@
setSingleFileProgress(fileIndex, file, 100); setSingleFileProgress(fileIndex, file, 100);
} }
updateStatus("Finalizing upload..."); await waitForUploadReady();
beginUploadFinalization();
const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken); const resultPayload = await completeResumableSession(session.sessionId, session.resumeToken);
const wasResumeMode = resumeMode; const wasResumeMode = resumeMode;
if (persistable) { if (persistable) {
removeResumableSession(session.sessionId); removeResumableSession(session.sessionId);
} }
activeUploadSession = null;
if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) { if (resumeMode && recoveredDraft && recoveredDraft.session.sessionId === session.sessionId) {
resumeMode = false; resumeMode = false;
recoveredDraft = null; recoveredDraft = null;
@@ -897,6 +1115,7 @@
function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) { function uploadChunk(sessionID, resumeToken, fileID, chunkIndex, chunk, onProgress) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = new XMLHttpRequest(); const request = new XMLHttpRequest();
activeUploadRequest = request;
request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`); request.open("PUT", `/api/v1/uploads/resumable/${encodeURIComponent(sessionID)}/files/${encodeURIComponent(fileID)}/chunks/${chunkIndex}`);
request.setRequestHeader("Accept", "application/json"); request.setRequestHeader("Accept", "application/json");
request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || ""); request.setRequestHeader("X-Warpbox-Resume-Token", resumeToken || "");
@@ -906,6 +1125,9 @@
} }
}); });
request.addEventListener("load", () => { request.addEventListener("load", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
if (request.status < 200 || request.status >= 300) { if (request.status < 200 || request.status >= 300) {
let payload = {}; let payload = {};
try { try {
@@ -918,8 +1140,24 @@
} }
resolve(); resolve();
}); });
request.addEventListener("error", () => reject(new Error("Network error during chunk upload"))); request.addEventListener("error", () => {
request.addEventListener("abort", () => reject(new Error("Chunk upload aborted"))); if (activeUploadRequest === request) {
activeUploadRequest = null;
}
reject(new Error("Network error during chunk upload"));
});
request.addEventListener("abort", () => {
if (activeUploadRequest === request) {
activeUploadRequest = null;
}
if (uploadCancelled) {
reject(uploadControlError("UploadCancelledError", "Upload cancelled"));
} else if (uploadPaused) {
reject(uploadControlError("UploadPausedError", "Upload paused"));
} else {
reject(new Error("Chunk upload aborted"));
}
});
request.send(chunk); request.send(chunk);
}); });
} }
@@ -928,16 +1166,26 @@
const delays = [1000, 2000, 5000, 10000, 20000]; const delays = [1000, 2000, 5000, 10000, 20000];
let lastError = null; let lastError = null;
for (let attempt = 0; attempt <= delays.length; attempt++) { for (let attempt = 0; attempt <= delays.length; attempt++) {
await waitForUploadReady();
try { try {
return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress); return await uploadChunk(session.sessionId, session.resumeToken, sessionFile.id, chunkIndex, chunk, onProgress);
} catch (error) { } catch (error) {
if (isUploadCancelledError(error)) {
throw error;
}
if (isUploadPausedError(error)) {
await waitForUploadReady();
await wait(150);
attempt -= 1;
continue;
}
lastError = error; lastError = error;
if (attempt >= delays.length) { if (attempt >= delays.length) {
break; break;
} }
const seconds = Math.round(delays[attempt] / 1000); const seconds = Math.round(delays[attempt] / 1000);
updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`); updateStatus(`Connection interrupted, retrying chunk ${chunkIndex + 1} in ${seconds}s`);
await wait(delays[attempt]); await waitForUploadDelay(delays[attempt]);
} }
} }
throw lastError || new Error("Chunk upload failed"); throw lastError || new Error("Chunk upload failed");
@@ -972,6 +1220,14 @@
return new Promise((resolve) => window.setTimeout(resolve, ms)); return new Promise((resolve) => window.setTimeout(resolve, ms));
} }
async function waitForUploadDelay(ms) {
const deadline = performance.now() + ms;
while (performance.now() < deadline) {
await wait(Math.min(100, Math.max(0, deadline - performance.now())));
await waitForUploadReady();
}
}
async function readUploadJSON(response, fallback) { async function readUploadJSON(response, fallback) {
let payload = {}; let payload = {};
try { try {

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

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

@@ -76,10 +76,14 @@
<div class="form-footer"> <div class="form-footer">
<p id="file-summary">Choose one or more files to begin.</p> <p id="file-summary">Choose one or more files to begin.</p>
<button class="button button-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button> <button class="button button-outline install-pwa-button upload-idle-action" type="button" data-install-pwa hidden>Install Warpbox</button>
<button class="button button-outline folder-picker-button" type="button" data-folder-picker hidden>Choose folder</button> <button class="button button-outline folder-picker-button upload-idle-action" type="button" data-folder-picker hidden>Choose folder</button>
<button class="button button-primary" type="submit">Upload files</button> <button class="button button-primary upload-idle-action" 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 upload-idle-action" type="button" id="new-upload" hidden>New upload</button>
<div class="upload-active-actions" id="upload-active-actions" hidden>
<button class="button button-danger" type="button" id="cancel-upload">Cancel Upload</button>
<button class="button button-outline" type="button" id="pause-upload">Pause Upload</button>
</div>
</div> </div>
</div> </div>
</div> </div>