feat(upload): support batching via header and update ShareX config
Introduce support for grouping multiple sequential file uploads into a single box using the `X-Warpbox-Batch` header. This is particularly useful for ShareX multi-file selections, which are sent as separate back-to-back requests.
Additionally, this change:
- Updates the ShareX configuration template to opt-in to batching by default.
- Switches ShareX configuration placeholders to the modern `{json:...}` format.
- Adds `thumbnailUrl` to the upload response schema and documents its usage.
This commit is contained in:
15
README.md
15
README.md
@@ -182,7 +182,7 @@ Curl and custom uploaders can use the same endpoint:
|
|||||||
# Terminal-friendly output: one plain box URL.
|
# Terminal-friendly output: one plain box URL.
|
||||||
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
|
||||||
|
|
||||||
# JSON output with boxUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
# JSON output with boxUrl, thumbnailUrl, manageUrl, deleteUrl, zipUrl, and file entries.
|
||||||
curl -F sharex=@./screenshot.png \
|
curl -F sharex=@./screenshot.png \
|
||||||
-H 'Accept: application/json' \
|
-H 'Accept: application/json' \
|
||||||
http://localhost:8080/api/v1/upload
|
http://localhost:8080/api/v1/upload
|
||||||
@@ -190,6 +190,19 @@ curl -F sharex=@./screenshot.png \
|
|||||||
|
|
||||||
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
|
||||||
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
|
||||||
|
Authenticated uploads (your account's limits) add an `Authorization: Bearer <token>` header — mint
|
||||||
|
a token under **Account → Access tokens**. The JSON response uses ShareX placeholders
|
||||||
|
`{json:boxUrl}` (URL), `{json:thumbnailUrl}` (thumbnail), `{json:deleteUrl}` (deletion), and
|
||||||
|
`{json:error}` (error message).
|
||||||
|
|
||||||
|
### Grouping multiple files into one box (`X-Warpbox-Batch`)
|
||||||
|
|
||||||
|
By default every uploaded file becomes its own box. To put several files in a **single** box, send
|
||||||
|
the opt-in `X-Warpbox-Batch` header: requests that share the same header value (scoped per account,
|
||||||
|
or per IP for anonymous uploads) within 20s are appended to the same box. This lets a multi-file
|
||||||
|
ShareX selection — which ShareX sends as separate back-to-back requests — land as one shareable
|
||||||
|
link. The shipped `.sxcu` sets `X-Warpbox-Batch: sharex`; remove that header for one box per file.
|
||||||
|
Requests without the header behave exactly as before.
|
||||||
|
|
||||||
## Stage 4 Accounts + Personal Boxes
|
## Stage 4 Accounts + Personal Boxes
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type apiDocsData struct {
|
|||||||
ShareXExampleURL string
|
ShareXExampleURL string
|
||||||
ShareXDownloadURL string
|
ShareXDownloadURL string
|
||||||
ShareXFileFieldName string
|
ShareXFileFieldName string
|
||||||
|
ShareXGroupWindow string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -33,6 +34,7 @@ func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
|
|||||||
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
|
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
|
||||||
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
|
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
|
||||||
ShareXFileFieldName: "sharex",
|
ShareXFileFieldName: "sharex",
|
||||||
|
ShareXGroupWindow: uploadGroupWindow.String(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -47,11 +49,16 @@ func (a *App) ShareXAnonymousConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"RequestURL": a.cfg.BaseURL + "/api/v1/upload",
|
"RequestURL": a.cfg.BaseURL + "/api/v1/upload",
|
||||||
"Headers": map[string]string{
|
"Headers": map[string]string{
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
// Group a multi-file selection (sent as back-to-back requests) into
|
||||||
|
// one box. Remove this header for one box per file.
|
||||||
|
uploadBatchHeader: "sharex",
|
||||||
},
|
},
|
||||||
"Body": "MultipartFormData",
|
"Body": "MultipartFormData",
|
||||||
"FileFormName": "sharex",
|
"FileFormName": "sharex",
|
||||||
"URL": "$json:boxUrl$",
|
"URL": "{json:boxUrl}",
|
||||||
"DeletionURL": "$json:manageUrl$",
|
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||||
|
"DeletionURL": "{json:deleteUrl}",
|
||||||
|
"ErrorMessage": "{json:error}",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,22 +116,24 @@ func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"},
|
"required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"boxId": map[string]any{"type": "string"},
|
"boxId": map[string]any{"type": "string"},
|
||||||
"boxUrl": map[string]any{"type": "string", "format": "uri"},
|
"boxUrl": map[string]any{"type": "string", "format": "uri"},
|
||||||
"zipUrl": map[string]any{"type": "string", "format": "uri"},
|
"zipUrl": map[string]any{"type": "string", "format": "uri"},
|
||||||
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
|
"thumbnailUrl": map[string]any{"type": "string", "format": "uri", "description": "Thumbnail of the most recently uploaded file (placeholder until generated)."},
|
||||||
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer POST URL for deleting this upload. Returned only at upload time."},
|
"manageUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for managing/deleting this upload. Returned only at upload time."},
|
||||||
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
|
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer URL for deleting this upload (GET or POST). Returned only at upload time."},
|
||||||
|
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
|
||||||
"files": map[string]any{
|
"files": map[string]any{
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": map[string]any{
|
"items": map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": []string{"id", "name", "size", "url"},
|
"required": []string{"id", "name", "size", "url"},
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"id": map[string]any{"type": "string"},
|
"id": map[string]any{"type": "string"},
|
||||||
"name": map[string]any{"type": "string"},
|
"name": map[string]any{"type": "string"},
|
||||||
"size": map[string]any{"type": "string"},
|
"size": map[string]any{"type": "string"},
|
||||||
"url": map[string]any{"type": "string", "format": "uri"},
|
"url": map[string]any{"type": "string", "format": "uri"},
|
||||||
|
"thumbnailUrl": map[string]any{"type": "string", "format": "uri"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type App struct {
|
|||||||
settingsService *services.SettingsService
|
settingsService *services.SettingsService
|
||||||
banService *services.BanService
|
banService *services.BanService
|
||||||
rateLimiter *rateLimiter
|
rateLimiter *rateLimiter
|
||||||
|
uploadGroups *uploadGrouper
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App {
|
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App {
|
||||||
@@ -30,6 +31,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
|||||||
settingsService: settingsService,
|
settingsService: settingsService,
|
||||||
banService: banService,
|
banService: banService,
|
||||||
rateLimiter: newRateLimiter(),
|
rateLimiter: newRateLimiter(),
|
||||||
|
uploadGroups: newUploadGrouper(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +114,9 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
|
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
|
||||||
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
|
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
|
||||||
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
||||||
|
// GET variant so ShareX (which issues a GET to the configured DeletionURL)
|
||||||
|
// can delete a box via its secret one-time delete token.
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
|
||||||
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
|
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
|
||||||
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
|
opts := services.UploadOptions{
|
||||||
MaxDays: maxDays,
|
MaxDays: maxDays,
|
||||||
ExpiresInMinutes: expiresMinutes,
|
ExpiresInMinutes: expiresMinutes,
|
||||||
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
MaxDownloads: parseInt(r.FormValue("max_downloads")),
|
||||||
@@ -103,14 +103,15 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
|
SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0,
|
||||||
CreatorIP: uploadClientIP(r),
|
CreatorIP: uploadClientIP(r),
|
||||||
StorageBackendID: effectivePolicy.StorageBackendID,
|
StorageBackendID: effectivePolicy.StorageBackendID,
|
||||||
})
|
}
|
||||||
|
result, boxesAdded, err := a.createOrAppendBox(r, user, loggedIn, files, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !isAdminUpload {
|
if !isAdminUpload {
|
||||||
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
|
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, boxesAdded); err != nil {
|
||||||
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
|
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
|
||||||
}
|
}
|
||||||
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
|
||||||
@@ -130,6 +131,67 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = fmt.Fprintln(w, result.BoxURL)
|
_, _ = fmt.Fprintln(w, result.BoxURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createOrAppendBox creates a new box. It only ever appends to an existing box
|
||||||
|
// when the request opts in via the X-Warpbox-Batch header: requests sharing the
|
||||||
|
// same batch value (per account, or per IP for anonymous) within
|
||||||
|
// uploadGroupWindow are folded into one box. Without the header the behaviour is
|
||||||
|
// identical to creating a fresh box every time. Returns the result and how many
|
||||||
|
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
|
||||||
|
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, files []*multipart.FileHeader, opts services.UploadOptions) (services.UploadResult, int, error) {
|
||||||
|
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
|
||||||
|
if batch == "" {
|
||||||
|
result, err := a.uploadService.CreateBox(files, opts)
|
||||||
|
if err != nil {
|
||||||
|
return services.UploadResult{}, 0, err
|
||||||
|
}
|
||||||
|
return result, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group key is scoped to the uploader so batches never cross accounts/IPs.
|
||||||
|
identity := "ip:" + uploadClientIP(r)
|
||||||
|
if loggedIn {
|
||||||
|
identity = "user:" + user.ID
|
||||||
|
}
|
||||||
|
entry := a.uploadGroups.entryFor(identity + "|" + batch)
|
||||||
|
|
||||||
|
// Hold the per-key lock across the whole create/append so concurrent batched
|
||||||
|
// uploads serialise into the same box instead of racing.
|
||||||
|
entry.mu.Lock()
|
||||||
|
defer entry.mu.Unlock()
|
||||||
|
|
||||||
|
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
|
||||||
|
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
|
||||||
|
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
|
||||||
|
// Re-attach the manage/delete URLs from the box's creation so every
|
||||||
|
// upload in the batch returns a working deletion URL.
|
||||||
|
result.ManageURL = entry.manageURL
|
||||||
|
result.DeleteURL = entry.deleteURL
|
||||||
|
entry.at = time.Now()
|
||||||
|
return result, 0, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := a.uploadService.CreateBox(files, opts)
|
||||||
|
if err != nil {
|
||||||
|
return services.UploadResult{}, 0, err
|
||||||
|
}
|
||||||
|
entry.boxID = result.BoxID
|
||||||
|
entry.manageURL = result.ManageURL
|
||||||
|
entry.deleteURL = result.DeleteURL
|
||||||
|
entry.at = time.Now()
|
||||||
|
return result, 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchBoxMatches guards that a batched append only ever touches a box owned by
|
||||||
|
// the same uploader (account for logged-in users, creator IP for anonymous).
|
||||||
|
func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn bool, r *http.Request) bool {
|
||||||
|
if loggedIn {
|
||||||
|
return box.OwnerID == user.ID
|
||||||
|
}
|
||||||
|
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
|
|||||||
49
backend/libs/handlers/upload_group.go
Normal file
49
backend/libs/handlers/upload_group.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uploadGroupWindow is how long after a batched upload a follow-up upload with
|
||||||
|
// the same X-Warpbox-Batch value (and same account/IP) is folded into the same
|
||||||
|
// box. ShareX sends a multi-file selection as separate back-to-back requests;
|
||||||
|
// the batch header lets it land them in one box.
|
||||||
|
const uploadGroupWindow = 20 * time.Second
|
||||||
|
|
||||||
|
// uploadBatchHeader is the opt-in request header. Without it, uploads behave
|
||||||
|
// exactly as before (one box per request). With it, requests sharing the same
|
||||||
|
// value (per account/IP) within uploadGroupWindow are grouped into one box.
|
||||||
|
const uploadBatchHeader = "X-Warpbox-Batch"
|
||||||
|
|
||||||
|
// uploadGrouper tracks the most recent box per batch key so opt-in batched
|
||||||
|
// uploads land in a single box. Each key has its own lock, which also serialises
|
||||||
|
// that key's concurrent uploads so they append to the same box instead of racing
|
||||||
|
// to create several.
|
||||||
|
type uploadGrouper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]*uploadGroupEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
type uploadGroupEntry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
boxID string
|
||||||
|
manageURL string
|
||||||
|
deleteURL string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUploadGrouper() *uploadGrouper {
|
||||||
|
return &uploadGrouper{entries: make(map[string]*uploadGroupEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *uploadGrouper) entryFor(key string) *uploadGroupEntry {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
entry, ok := g.entries[key]
|
||||||
|
if !ok {
|
||||||
|
entry = &uploadGroupEntry{}
|
||||||
|
g.entries[key] = entry
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
@@ -82,20 +82,22 @@ type File struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
BoxID string `json:"boxId"`
|
BoxID string `json:"boxId"`
|
||||||
BoxURL string `json:"boxUrl"`
|
BoxURL string `json:"boxUrl"`
|
||||||
ZipURL string `json:"zipUrl"`
|
ZipURL string `json:"zipUrl"`
|
||||||
ManageURL string `json:"manageUrl"`
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
DeleteURL string `json:"deleteUrl"`
|
ManageURL string `json:"manageUrl"`
|
||||||
ExpiresAt string `json:"expiresAt"`
|
DeleteURL string `json:"deleteUrl"`
|
||||||
Files []ResultFile `json:"files"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
Files []ResultFile `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResultFile struct {
|
type ResultFile struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
ThumbnailURL string `json:"thumbnailUrl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminStats struct {
|
type AdminStats struct {
|
||||||
@@ -226,15 +228,66 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
box.PasswordHash = hash
|
box.PasswordHash = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
backend, err := s.storage.Backend(box.StorageBackendID)
|
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.SaveBox(box); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("upload complete",
|
||||||
|
"source", "user-upload",
|
||||||
|
"severity", "user_activity",
|
||||||
|
"code", 2001,
|
||||||
|
"box_id", box.ID,
|
||||||
|
"file_count", len(box.Files),
|
||||||
|
)
|
||||||
|
|
||||||
|
return s.resultForBox(box, deleteToken), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendFiles adds files to an existing box (used to group a ShareX multi-file
|
||||||
|
// selection into a single box). The box keeps its original expiry, password and
|
||||||
|
// other settings; only the new files are written.
|
||||||
|
func (s *UploadService) AppendFiles(boxID string, files []*multipart.FileHeader, opts UploadOptions) (UploadResult, error) {
|
||||||
|
if len(files) == 0 {
|
||||||
|
return UploadResult{}, fmt.Errorf("no files were uploaded")
|
||||||
|
}
|
||||||
|
box, err := s.GetBox(boxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UploadResult{}, err
|
return UploadResult{}, err
|
||||||
}
|
}
|
||||||
|
if err := s.writeFilesToBox(&box, files, opts); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
if err := s.SaveBox(box); err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
s.logger.Info("upload appended",
|
||||||
|
"source", "user-upload",
|
||||||
|
"severity", "user_activity",
|
||||||
|
"code", 2001,
|
||||||
|
"box_id", box.ID,
|
||||||
|
"added", len(files),
|
||||||
|
"file_count", len(box.Files),
|
||||||
|
)
|
||||||
|
return s.resultForBox(box, ""), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFilesToBox streams each uploaded file into the box's storage backend and
|
||||||
|
// appends the file metadata to box.Files. The box's StorageBackendID determines
|
||||||
|
// where files land, so it works for both new and existing boxes.
|
||||||
|
func (s *UploadService) writeFilesToBox(box *Box, files []*multipart.FileHeader, opts UploadOptions) error {
|
||||||
|
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, header := range files {
|
for _, header := range files {
|
||||||
if !opts.SkipSizeLimit {
|
if !opts.SkipSizeLimit {
|
||||||
if err := s.ValidateSize(header.Size); err != nil {
|
if err := s.ValidateSize(header.Size); err != nil {
|
||||||
return UploadResult{}, err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +298,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
|
|
||||||
file, err := header.Open()
|
file, err := header.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return UploadResult{}, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fileID := randomID(8)
|
fileID := randomID(8)
|
||||||
@@ -263,7 +316,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
|
|
||||||
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
|
||||||
file.Close()
|
file.Close()
|
||||||
return UploadResult{}, err
|
return err
|
||||||
}
|
}
|
||||||
file.Close()
|
file.Close()
|
||||||
|
|
||||||
@@ -278,20 +331,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
|
|||||||
UploadedAt: time.Now().UTC(),
|
UploadedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
if err := s.SaveBox(box); err != nil {
|
|
||||||
return UploadResult{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.logger.Info("upload complete",
|
|
||||||
"source", "user-upload",
|
|
||||||
"severity", "user_activity",
|
|
||||||
"code", 2001,
|
|
||||||
"box_id", box.ID,
|
|
||||||
"file_count", len(box.Files),
|
|
||||||
)
|
|
||||||
|
|
||||||
return s.resultForBox(box, deleteToken), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UploadService) GetBox(id string) (Box, error) {
|
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||||
@@ -726,19 +766,28 @@ func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
|
|||||||
files := make([]ResultFile, 0, len(box.Files))
|
files := make([]ResultFile, 0, len(box.Files))
|
||||||
for _, file := range box.Files {
|
for _, file := range box.Files {
|
||||||
files = append(files, ResultFile{
|
files = append(files, ResultFile{
|
||||||
ID: file.ID,
|
ID: file.ID,
|
||||||
Name: file.Name,
|
Name: file.Name,
|
||||||
Size: helpers.FormatBytes(file.Size),
|
Size: helpers.FormatBytes(file.Size),
|
||||||
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
|
URL: fmt.Sprintf("%s/d/%s/f/%s", s.baseURL, box.ID, file.ID),
|
||||||
|
ThumbnailURL: fmt.Sprintf("%s/d/%s/thumb/%s", s.baseURL, box.ID, file.ID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The box-level thumbnail points at the most recently added file, so a
|
||||||
|
// per-file ShareX upload previews the file it just sent.
|
||||||
|
thumbnailURL := fmt.Sprintf("%s/d/%s/og-image.jpg", s.baseURL, box.ID)
|
||||||
|
if len(files) > 0 {
|
||||||
|
thumbnailURL = files[len(files)-1].ThumbnailURL
|
||||||
|
}
|
||||||
|
|
||||||
result := UploadResult{
|
result := UploadResult{
|
||||||
BoxID: box.ID,
|
BoxID: box.ID,
|
||||||
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
|
||||||
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
|
||||||
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
ThumbnailURL: thumbnailURL,
|
||||||
Files: files,
|
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
|
||||||
|
Files: files,
|
||||||
}
|
}
|
||||||
if deleteToken != "" {
|
if deleteToken != "" {
|
||||||
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
|
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
|
||||||
|
|||||||
@@ -36,6 +36,22 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docs-card h3 {
|
||||||
|
margin: 1.35rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlights where the API token goes in the ShareX config snippet. */
|
||||||
|
.sxcu-highlight {
|
||||||
|
background: #fde047;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.docs-card p {
|
.docs-card p {
|
||||||
margin: 0.65rem 0 0;
|
margin: 0.65rem 0 0;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}} · self-hosted</span>
|
<span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}}</span>
|
||||||
<label class="theme-picker">
|
<label class="theme-picker">
|
||||||
<span>Theme</span>
|
<span>Theme</span>
|
||||||
<select data-theme-select aria-label="Site theme">
|
<select data-theme-select aria-label="Site theme">
|
||||||
|
|||||||
@@ -36,11 +36,12 @@
|
|||||||
<article class="card docs-card">
|
<article class="card docs-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>JSON response</h2>
|
<h2>JSON response</h2>
|
||||||
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private.</p>
|
<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>
|
||||||
<pre><code>{
|
<pre><code>{
|
||||||
"boxId": "abc123",
|
"boxId": "abc123",
|
||||||
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
|
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
|
||||||
"zipUrl": "{{.Data.BaseURL}}/d/abc123/zip",
|
"zipUrl": "{{.Data.BaseURL}}/d/abc123/zip",
|
||||||
|
"thumbnailUrl": "{{.Data.BaseURL}}/d/abc123/thumb/file123",
|
||||||
"manageUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token",
|
"manageUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token",
|
||||||
"deleteUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token/delete",
|
"deleteUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token/delete",
|
||||||
"expiresAt": "2026-06-05T12:00:00Z",
|
"expiresAt": "2026-06-05T12:00:00Z",
|
||||||
@@ -49,7 +50,8 @@
|
|||||||
"id": "file123",
|
"id": "file123",
|
||||||
"name": "report.pdf",
|
"name": "report.pdf",
|
||||||
"size": "2.4 MiB",
|
"size": "2.4 MiB",
|
||||||
"url": "{{.Data.BaseURL}}/d/abc123/f/file123"
|
"url": "{{.Data.BaseURL}}/d/abc123/f/file123",
|
||||||
|
"thumbnailUrl": "{{.Data.BaseURL}}/d/abc123/thumb/file123"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}</code></pre>
|
}</code></pre>
|
||||||
@@ -59,22 +61,44 @@
|
|||||||
<article class="card docs-card">
|
<article class="card docs-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<h2>ShareX setup</h2>
|
<h2>ShareX setup</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h3>1 · Import the uploader</h3>
|
||||||
<ol class="docs-steps">
|
<ol class="docs-steps">
|
||||||
<li>Download the instance config: <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>/api/v1/sharex/warpbox-anonymous.sxcu</code></a>.</li>
|
<li>Download <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>warpbox-anonymous.sxcu</code></a>.</li>
|
||||||
<li>Or open the tracked template at <code>{{.Data.ShareXExamplePath}}</code> and change <code>RequestURL</code> to <code>{{.Data.ShareXExampleURL}}</code>.</li>
|
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
|
||||||
<li>Keep <code>FileFormName</code> as <code>{{.Data.ShareXFileFieldName}}</code>.</li>
|
|
||||||
<li>Import the <code>.sxcu</code> file into ShareX as a custom uploader.</li>
|
|
||||||
<li>Upload a file. ShareX will use <code>boxUrl</code> as the public URL and <code>manageUrl</code> as the deletion URL.</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
<h3>2 · Add your API key (upload as your account)</h3>
|
||||||
|
<ol class="docs-steps">
|
||||||
|
<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>Add a header — Name <code>Authorization</code>, Value <code>Bearer <your token></code>.</li>
|
||||||
|
</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>
|
||||||
|
|
||||||
<pre><code>{
|
<pre><code>{
|
||||||
|
"Version": "1.0.0",
|
||||||
|
"Name": "Warpbox (my account)",
|
||||||
|
"DestinationType": "ImageUploader, FileUploader, TextUploader",
|
||||||
"RequestMethod": "POST",
|
"RequestMethod": "POST",
|
||||||
"RequestURL": "{{.Data.ShareXExampleURL}}",
|
"RequestURL": "{{.Data.ShareXExampleURL}}",
|
||||||
"Headers": { "Accept": "application/json" },
|
"Headers": {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": "Bearer <span class="sxcu-highlight">YOUR_API_TOKEN</span>",
|
||||||
|
"X-Warpbox-Batch": "sharex"
|
||||||
|
},
|
||||||
"Body": "MultipartFormData",
|
"Body": "MultipartFormData",
|
||||||
"FileFormName": "{{.Data.ShareXFileFieldName}}",
|
"FileFormName": "{{.Data.ShareXFileFieldName}}",
|
||||||
"URL": "$json:boxUrl$",
|
"URL": "{json:boxUrl}",
|
||||||
"DeletionURL": "$json:manageUrl$"
|
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||||
|
"DeletionURL": "{json:deleteUrl}",
|
||||||
|
"ErrorMessage": "{json:error}"
|
||||||
}</code></pre>
|
}</code></pre>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
"RequestMethod": "POST",
|
"RequestMethod": "POST",
|
||||||
"RequestURL": "https://warpbox.dev/api/v1/upload",
|
"RequestURL": "https://warpbox.dev/api/v1/upload",
|
||||||
"Headers": {
|
"Headers": {
|
||||||
"Accept": "application/json"
|
"Accept": "application/json",
|
||||||
|
"X-Warpbox-Batch": "sharex"
|
||||||
},
|
},
|
||||||
"Body": "MultipartFormData",
|
"Body": "MultipartFormData",
|
||||||
"FileFormName": "sharex",
|
"FileFormName": "sharex",
|
||||||
"URL": "$json:boxUrl$",
|
"URL": "{json:boxUrl}",
|
||||||
"DeletionURL": "$json:manageUrl$"
|
"ThumbnailURL": "{json:thumbnailUrl}",
|
||||||
|
"DeletionURL": "{json:deleteUrl}",
|
||||||
|
"ErrorMessage": "{json:error}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user