Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78b767a4a2 | |||
| dc4aee8ca2 | |||
| e2cf7115b7 | |||
| a0027fbd18 |
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
81
backend/static/api/warpbox.ps1
Normal file
81
backend/static/api/warpbox.ps1
Normal 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
|
||||||
120
backend/static/api/warpbox.sh
Normal file
120
backend/static/api/warpbox.sh
Normal 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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
94
backend/static/js/48-api-docs.js
Normal file
94
backend/static/js/48-api-docs.js
Normal 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);
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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 & 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 & 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 <token></code> header on every resumable request. Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="card docs-card">
|
|
||||||
<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 & 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 <token></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 > 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 <token></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 & 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 & 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 & 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 { & "$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 <token></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 <your token></code>.</li>
|
<li>Add a header. Name <code>Authorization</code>, Value <code>Bearer <your token></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 <token>'</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 & 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 & 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}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user