feat(api): add API documentation and ShareX integration

- Add an API documentation page with curl and ShareX examples.
- Implement a dynamic ShareX configuration endpoint (`/api/v1/sharex/warpbox-anonymous.sxcu`) that generates a `.sxcu` file pre-configured with the instance's base URL.
- Update anonymous uploads to return a private management link (`manageUrl`) and a deletion link (`deleteUrl`) in JSON responses.
- Update README with details on Stage 3 Anonymous Integrations.
- Add styling for the new API documentation view and management details.
This commit is contained in:
2026-05-29 23:44:05 +03:00
parent 74ede000b4
commit 3471e2b0cf
19 changed files with 1231 additions and 46 deletions

View File

@@ -54,6 +54,30 @@ go run ./cmd/warpbox
- Expired boxes and boxes that have reached their download limit are cleaned on startup and then every `WARPBOX_CLEANUP_EVERY` when `WARPBOX_CLEANUP_ENABLED=true`.
- Missing image/video thumbnails are generated in a background worker every `WARPBOX_THUMBNAIL_EVERY` when `WARPBOX_THUMBNAIL_ENABLED=true`.
## Stage 3 Anonymous Integrations
Anonymous uploads now return a private management link at creation time. Keep that link secret:
anyone with it can delete the entire upload box. The raw delete token is not stored and cannot be
recovered later.
Browser uploads still show `Open box` and `Copy URL` as the primary actions, with a smaller
`Manage or delete this upload` link in the completion panel.
Curl and custom uploaders can use the same endpoint:
```bash
# Terminal-friendly output: one plain box URL.
curl -F file=@./report.pdf http://localhost:8080/api/v1/upload
# JSON output with boxUrl, manageUrl, deleteUrl, zipUrl, and file entries.
curl -F sharex=@./screenshot.png \
-H 'Accept: application/json' \
http://localhost:8080/api/v1/upload
```
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
## Runtime Data
Warpbox keeps local runtime data under the configured data directory:

View File

@@ -0,0 +1,133 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/web"
)
type apiDocsData struct {
BaseURL string
UploadURL string
HealthURL string
RequestSchemaURL string
ResponseSchemaURL string
ShareXExamplePath string
ShareXExampleURL string
ShareXDownloadURL string
ShareXFileFieldName string
}
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "api.html", web.PageData{
Title: "API documentation",
Description: "Curl and ShareX upload examples for Warpbox.",
Data: apiDocsData{
BaseURL: a.cfg.BaseURL,
UploadURL: a.cfg.BaseURL + "/api/v1/upload",
HealthURL: a.cfg.BaseURL + "/api/v1/health",
RequestSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-request.json",
ResponseSchemaURL: a.cfg.BaseURL + "/api/v1/schemas/upload-response.json",
ShareXExamplePath: "examples/sharex/warpbox-anonymous.sxcu",
ShareXExampleURL: a.cfg.BaseURL + "/api/v1/upload",
ShareXDownloadURL: a.cfg.BaseURL + "/api/v1/sharex/warpbox-anonymous.sxcu",
ShareXFileFieldName: "sharex",
},
})
}
func (a *App) ShareXAnonymousConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="warpbox-anonymous.sxcu"`)
helpers.WriteJSON(w, http.StatusOK, map[string]any{
"Version": "18.0.0",
"Name": "Warpbox Anonymous Upload",
"DestinationType": "ImageUploader, TextUploader, FileUploader",
"RequestMethod": "POST",
"RequestURL": a.cfg.BaseURL + "/api/v1/upload",
"Headers": map[string]string{
"Accept": "application/json",
},
"Body": "MultipartFormData",
"FileFormName": "sharex",
"URL": "$json:boxUrl$",
"DeletionURL": "$json:manageUrl$",
})
}
func (a *App) UploadRequestSchema(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": a.cfg.BaseURL + "/api/v1/schemas/upload-request.json",
"title": "Warpbox anonymous upload request",
"description": "Multipart/form-data request accepted by POST /api/v1/upload. Send one or more files using either the file or sharex field.",
"type": "object",
"properties": map[string]any{
"file": map[string]any{
"description": "One or more uploaded files. Use this field for curl and browser-style clients.",
"type": "array",
"items": map[string]any{"type": "string", "format": "binary"},
},
"sharex": map[string]any{
"description": "One or more uploaded files. Use this field for ShareX custom uploader configs.",
"type": "array",
"items": map[string]any{"type": "string", "format": "binary"},
},
"max_days": map[string]any{
"description": "Optional number of days before the box expires. Defaults to 7.",
"type": "integer",
"minimum": 1,
},
"max_downloads": map[string]any{
"description": "Optional maximum number of downloads before the box expires.",
"type": "integer",
"minimum": 1,
},
"password": map[string]any{
"description": "Optional box password.",
"type": "string",
},
"obfuscate_metadata": map[string]any{
"description": "Optional checkbox-style value. When set with a password, hides file names/counts until unlock.",
"type": "string",
"enum": []string{"on"},
},
},
"anyOf": []any{
map[string]any{"required": []string{"file"}},
map[string]any{"required": []string{"sharex"}},
},
})
}
func (a *App) UploadResponseSchema(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, map[string]any{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": a.cfg.BaseURL + "/api/v1/schemas/upload-response.json",
"title": "Warpbox anonymous upload JSON response",
"description": "JSON response returned by POST /api/v1/upload when Accept: application/json is sent.",
"type": "object",
"required": []string{"boxId", "boxUrl", "zipUrl", "manageUrl", "deleteUrl", "expiresAt", "files"},
"properties": map[string]any{
"boxId": map[string]any{"type": "string"},
"boxUrl": 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."},
"deleteUrl": map[string]any{"type": "string", "format": "uri", "description": "Private bearer POST URL for deleting this upload. Returned only at upload time."},
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
"files": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"required": []string{"id", "name", "size", "url"},
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
"size": map[string]any{"type": "string"},
"url": map[string]any{"type": "string", "format": "uri"},
},
},
},
},
})
}

View File

@@ -27,6 +27,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs)
mux.HandleFunc("GET /admin/login", a.AdminLogin)
mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
mux.HandleFunc("POST /admin/logout", a.AdminLogout)
@@ -35,6 +36,9 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)
mux.HandleFunc("GET /d/{boxID}/manage/{token}", a.ManageBox)
mux.HandleFunc("POST /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox)
mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox)
mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
@@ -42,6 +46,9 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("GET /api/v1/sharex/warpbox-anonymous.sxcu", a.ShareXAnonymousConfig)
mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema)
mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema)
mux.HandleFunc("POST /api/v1/upload", a.Upload)
mux.Handle("GET /static/", a.Static())
}

View File

@@ -0,0 +1,87 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type managePageData struct {
Box boxView
Token string
FileCount int
TotalSize string
ExpiresLabel string
DownloadCount int
MaxDownloads int
Protected bool
DeleteActionURL string
}
func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
box, ok := a.loadManagedBox(w, r)
if !ok {
return
}
a.renderer.Render(w, http.StatusOK, "manage.html", web.PageData{
Title: "Manage upload",
Description: "Delete this anonymous Warpbox upload.",
Data: a.managePageData(box, r.PathValue("token")),
})
}
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
box, ok := a.loadManagedBox(w, r)
if !ok {
return
}
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
}
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "manage_deleted.html", web.PageData{
Title: "Upload deleted",
Description: "This Warpbox upload has been deleted.",
Data: boxView{ID: r.PathValue("boxID")},
})
}
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil {
http.NotFound(w, r)
return services.Box{}, false
}
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
http.NotFound(w, r)
return services.Box{}, false
}
return box, true
}
func (a *App) managePageData(box services.Box, token string) managePageData {
var totalSize int64
for _, file := range box.Files {
totalSize += file.Size
}
return managePageData{
Box: boxView{ID: box.ID},
Token: token,
FileCount: len(box.Files),
TotalSize: helpers.FormatBytes(totalSize),
ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"),
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: a.uploadService.IsProtected(box),
DeleteActionURL: "/d/" + box.ID + "/manage/" + token + "/delete",
}
}

View File

@@ -2,10 +2,14 @@ package handlers
import (
"errors"
"fmt"
"mime/multipart"
"net/http"
"strconv"
"strings"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
)
@@ -16,7 +20,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
files := r.MultipartForm.File["file"]
files := uploadFiles(r)
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")),
@@ -28,8 +32,16 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
if wantsJSON(r) {
helpers.WriteJSON(w, http.StatusCreated, result)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintln(w, result.BoxURL)
}
func parseInt(value string) int {
@@ -49,3 +61,17 @@ func statusForDownloadError(err error) int {
}
return http.StatusForbidden
}
func uploadFiles(r *http.Request) []*multipart.FileHeader {
if r.MultipartForm == nil {
return nil
}
files := make([]*multipart.FileHeader, 0)
files = append(files, r.MultipartForm.File["file"]...)
files = append(files, r.MultipartForm.File["sharex"]...)
return files
}
func wantsJSON(r *http.Request) bool {
return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json")
}

View File

@@ -0,0 +1,268 @@
package handlers
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := multipartUploadRequest(t, "/api/v1/upload", "sharex", "shot.png", "image data")
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload.BoxURL == "" || payload.ManageURL == "" || payload.DeleteURL == "" {
t.Fatalf("upload response missing URLs: %+v", payload)
}
if !strings.Contains(payload.ManageURL, "/manage/") || !strings.HasSuffix(payload.DeleteURL, "/delete") {
t.Fatalf("unexpected manage/delete URLs: %+v", payload)
}
if len(payload.Files) != 1 || payload.Files[0].Name != "shot.png" {
t.Fatalf("unexpected files: %+v", payload.Files)
}
}
func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
got := response.Body.String()
if !strings.HasPrefix(got, "http://example.test/d/") || !strings.HasSuffix(got, "\n") {
t.Fatalf("text response = %q", got)
}
if strings.Contains(got, "{") || strings.Contains(got, "manage") {
t.Fatalf("text response leaked JSON/manage data: %q", got)
}
}
func TestManageBoxAndDeleteFlow(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
result := uploadThroughApp(t, app)
token := tokenFromURL(t, result.ManageURL)
manageRequest := httptest.NewRequest(http.MethodGet, "/d/"+result.BoxID+"/manage/"+token, nil)
manageRequest.SetPathValue("boxID", result.BoxID)
manageRequest.SetPathValue("token", token)
manageResponse := httptest.NewRecorder()
app.ManageBox(manageResponse, manageRequest)
if manageResponse.Code != http.StatusOK {
t.Fatalf("manage status = %d, body = %s", manageResponse.Code, manageResponse.Body.String())
}
if !strings.Contains(manageResponse.Body.String(), "Delete upload") {
t.Fatalf("manage page did not render confirmation")
}
deleteRequest := httptest.NewRequest(http.MethodPost, "/d/"+result.BoxID+"/manage/"+token+"/delete", nil)
deleteRequest.SetPathValue("boxID", result.BoxID)
deleteRequest.SetPathValue("token", token)
deleteResponse := httptest.NewRecorder()
app.ManageDeleteBox(deleteResponse, deleteRequest)
if deleteResponse.Code != http.StatusSeeOther {
t.Fatalf("delete status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String())
}
if location := deleteResponse.Header().Get("Location"); location != "/d/"+result.BoxID+"/deleted" {
t.Fatalf("delete redirect = %q", location)
}
if _, err := app.uploadService.GetBox(result.BoxID); !os.IsNotExist(err) {
t.Fatalf("GetBox after delete error = %v, want os.ErrNotExist", err)
}
}
func TestManageBoxRejectsInvalidToken(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
result := uploadThroughApp(t, app)
request := httptest.NewRequest(http.MethodGet, "/d/"+result.BoxID+"/manage/wrong", nil)
request.SetPathValue("boxID", result.BoxID)
request.SetPathValue("token", "wrong")
response := httptest.NewRecorder()
app.ManageBox(response, request)
if response.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound)
}
if strings.Contains(response.Body.String(), result.BoxID) {
t.Fatalf("invalid token response leaked box id")
}
}
func TestAPIDocsAndSchemas(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
docsResponse := httptest.NewRecorder()
app.APIDocs(docsResponse, httptest.NewRequest(http.MethodGet, "/api", nil))
if docsResponse.Code != http.StatusOK {
t.Fatalf("docs status = %d, body = %s", docsResponse.Code, docsResponse.Body.String())
}
if !strings.Contains(docsResponse.Body.String(), "/api/v1/schemas/upload-request.json") ||
!strings.Contains(docsResponse.Body.String(), "ShareX setup") {
t.Fatalf("API docs missing schema links or ShareX tutorial")
}
requestSchemaResponse := httptest.NewRecorder()
app.UploadRequestSchema(requestSchemaResponse, httptest.NewRequest(http.MethodGet, "/api/v1/schemas/upload-request.json", nil))
if requestSchemaResponse.Code != http.StatusOK {
t.Fatalf("request schema status = %d", requestSchemaResponse.Code)
}
if !strings.Contains(requestSchemaResponse.Body.String(), `"sharex"`) {
t.Fatalf("request schema missing sharex field")
}
responseSchemaResponse := httptest.NewRecorder()
app.UploadResponseSchema(responseSchemaResponse, httptest.NewRequest(http.MethodGet, "/api/v1/schemas/upload-response.json", nil))
if responseSchemaResponse.Code != http.StatusOK {
t.Fatalf("response schema status = %d", responseSchemaResponse.Code)
}
if !strings.Contains(responseSchemaResponse.Body.String(), `"manageUrl"`) {
t.Fatalf("response schema missing manageUrl field")
}
shareXResponse := httptest.NewRecorder()
app.ShareXAnonymousConfig(shareXResponse, httptest.NewRequest(http.MethodGet, "/api/v1/sharex/warpbox-anonymous.sxcu", nil))
if shareXResponse.Code != http.StatusOK {
t.Fatalf("ShareX config status = %d", shareXResponse.Code)
}
if !strings.Contains(shareXResponse.Body.String(), `"FileFormName":"sharex"`) ||
!strings.Contains(shareXResponse.Body.String(), `"RequestURL":"http://example.test/api/v1/upload"`) {
t.Fatalf("ShareX config did not include instance upload settings: %s", shareXResponse.Body.String())
}
}
func newTestApp(t *testing.T) (*App, func()) {
t.Helper()
root := t.TempDir()
templateDir := filepath.Join(root, "templates")
if err := copyDir(filepath.Join("..", "..", "templates"), templateDir); err != nil {
t.Fatalf("copy templates: %v", err)
}
staticDir := filepath.Join(root, "static")
if err := copyDir(filepath.Join("..", "..", "static"), staticDir); err != nil {
t.Fatalf("copy static: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{
AppName: "warpbox.dev",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024,
}
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
if err != nil {
service.Close()
t.Fatalf("NewRenderer returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service), func() {
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}
}
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
t.Helper()
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("upload status = %d, body = %s", response.Code, response.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
return payload
}
func multipartUploadRequest(t *testing.T, path, field, filename, body string) *http.Request {
t.Helper()
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile returned error: %v", err)
}
if _, err := part.Write([]byte(body)); err != nil {
t.Fatalf("part.Write returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, path, &payload)
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func tokenFromURL(t *testing.T, value string) string {
t.Helper()
parts := strings.Split(strings.TrimRight(value, "/"), "/")
if len(parts) == 0 {
t.Fatalf("empty URL")
}
return parts[len(parts)-1]
}
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
}

View File

@@ -23,6 +23,25 @@ type thumbnailJobResult struct {
Failed int
}
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
go func() {
box, err := uploadService.GetBox(boxID)
if err != nil {
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
return
}
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
if err != nil {
logger.Warn("thumbnail one-shot job failed", "source", "thumbnail", "severity", "warn", "code", 4205, "box_id", boxID, "error", err.Error())
return
}
if result.Generated > 0 || result.Failed > 0 {
logger.Info("thumbnail one-shot job complete", "source", "thumbnail", "severity", "user_activity", "code", 2205, "box_id", boxID, "generated", result.Generated, "failed", result.Failed)
}
}()
}
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
return job{
name: "thumbnail",
@@ -54,6 +73,24 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
continue
}
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
result.Scanned += boxResult.Scanned
result.Generated += boxResult.Generated
result.Failed += boxResult.Failed
if err != nil {
return result, err
}
}
return result, nil
}
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (thumbnailJobResult, error) {
var result thumbnailJobResult
if !box.ExpiresAt.After(time.Now().UTC()) {
return result, nil
}
changed := false
for i := range box.Files {
file := &box.Files[i]
@@ -83,8 +120,6 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
return result, err
}
}
}
return result, nil
}

View File

@@ -0,0 +1,103 @@
package jobs
import (
"bytes"
"image"
"image/color"
"image/png"
"io"
"log/slog"
"mime/multipart"
"net/http/httptest"
"net/textproto"
"testing"
"warpbox.dev/backend/libs/services"
)
func TestGenerateMissingThumbnailsForBox(t *testing.T) {
service := newThumbnailTestUploadService(t)
result := createThumbnailTestBox(t, service)
box, err := service.GetBox(result.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
if box.Files[0].Thumbnail != "" {
t.Fatalf("thumbnail should start empty")
}
jobResult, err := generateMissingThumbnailsForBox(service, slog.New(slog.NewTextHandler(io.Discard, nil)), box)
if err != nil {
t.Fatalf("generateMissingThumbnailsForBox returned error: %v", err)
}
if jobResult.Generated != 1 || jobResult.Failed != 0 {
t.Fatalf("job result = %+v, want 1 generated and 0 failed", jobResult)
}
updated, err := service.GetBox(result.BoxID)
if err != nil {
t.Fatalf("GetBox after thumbnail returned error: %v", err)
}
if updated.Files[0].Thumbnail == "" {
t.Fatalf("thumbnail was not saved to box metadata")
}
if service.ThumbnailPath(updated, updated.Files[0]) == "" {
t.Fatalf("thumbnail path was empty")
}
}
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
t.Helper()
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
return service
}
func createThumbnailTestBox(t *testing.T, service *services.UploadService) services.UploadResult {
t.Helper()
result, err := service.CreateBox(thumbnailTestFileHeaders(t), services.UploadOptions{MaxDays: 1})
if err != nil {
t.Fatalf("CreateBox returned error: %v", err)
}
return result
}
func thumbnailTestFileHeaders(t *testing.T) []*multipart.FileHeader {
t.Helper()
var imageData bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
img.Set(0, 0, color.RGBA{R: 255, A: 255})
if err := png.Encode(&imageData, img); err != nil {
t.Fatalf("png.Encode returned error: %v", err)
}
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="thumb.png"`)
header.Set("Content-Type", "image/png")
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("CreateFormFile returned error: %v", err)
}
if _, err := part.Write(imageData.Bytes()); err != nil {
t.Fatalf("part.Write returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
request := httptest.NewRequest("POST", "/upload", &payload)
request.Header.Set("Content-Type", writer.FormDataContentType())
if err := request.ParseMultipartForm(1024 * 1024); err != nil {
t.Fatalf("ParseMultipartForm returned error: %v", err)
}
return request.MultipartForm.File["file"]
}

View File

@@ -47,6 +47,7 @@ type Box struct {
DownloadCount int `json:"downloadCount"`
PasswordSalt string `json:"passwordSalt,omitempty"`
PasswordHash string `json:"passwordHash,omitempty"`
DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
Obfuscate bool `json:"obfuscate"`
Files []File `json:"files"`
}
@@ -66,6 +67,8 @@ type UploadResult struct {
BoxID string `json:"boxId"`
BoxURL string `json:"boxUrl"`
ZipURL string `json:"zipUrl"`
ManageURL string `json:"manageUrl"`
DeleteURL string `json:"deleteUrl"`
ExpiresAt string `json:"expiresAt"`
Files []ResultFile `json:"files"`
}
@@ -169,6 +172,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
Files: make([]File, 0, len(files)),
}
deleteToken := randomID(32)
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
if strings.TrimSpace(opts.Password) != "" {
salt, hash := hashPassword(opts.Password)
box.PasswordSalt = salt
@@ -227,7 +232,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
"file_count", len(box.Files),
)
return s.resultForBox(box), nil
return s.resultForBox(box, deleteToken), nil
}
func (s *UploadService) GetBox(id string) (Box, error) {
@@ -327,6 +332,17 @@ func (s *UploadService) DeleteBox(boxID string) error {
return s.DeleteBoxWithSource(boxID, "admin")
}
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if !s.VerifyDeleteToken(box, token) {
return os.ErrPermission
}
return s.DeleteBoxWithSource(boxID, "anonymous-delete")
}
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
if err := s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(boxesBucket).Delete([]byte(boxID))
@@ -381,6 +397,14 @@ func (s *UploadService) UnlockToken(box Box) string {
return hex.EncodeToString(sum[:])
}
func (s *UploadService) VerifyDeleteToken(box Box, token string) bool {
if box.DeleteTokenHash == "" || strings.TrimSpace(token) == "" {
return false
}
hash := deleteTokenHash(box.ID, token)
return subtle.ConstantTimeCompare([]byte(hash), []byte(box.DeleteTokenHash)) == 1
}
func (s *UploadService) CanDownload(box Box) error {
if time.Now().UTC().After(box.ExpiresAt) {
return fmt.Errorf("box has expired")
@@ -462,7 +486,7 @@ func (s *UploadService) SaveBox(box Box) error {
})
}
func (s *UploadService) resultForBox(box Box) UploadResult {
func (s *UploadService) resultForBox(box Box, deleteToken string) UploadResult {
files := make([]ResultFile, 0, len(box.Files))
for _, file := range box.Files {
files = append(files, ResultFile{
@@ -473,13 +497,18 @@ func (s *UploadService) resultForBox(box Box) UploadResult {
})
}
return UploadResult{
result := UploadResult{
BoxID: box.ID,
BoxURL: fmt.Sprintf("%s/d/%s", s.baseURL, box.ID),
ZipURL: fmt.Sprintf("%s/d/%s/zip", s.baseURL, box.ID),
ExpiresAt: box.ExpiresAt.Format(time.RFC3339),
Files: files,
}
if deleteToken != "" {
result.ManageURL = fmt.Sprintf("%s/d/%s/manage/%s", s.baseURL, box.ID, deleteToken)
result.DeleteURL = fmt.Sprintf("%s/d/%s/manage/%s/delete", s.baseURL, box.ID, deleteToken)
}
return result
}
func writeUploadedFile(path string, source multipart.File, maxSize int64) error {
@@ -519,6 +548,11 @@ func passwordHash(salt, password string) string {
return hex.EncodeToString(sum[:])
}
func deleteTokenHash(boxID, token string) string {
sum := sha256.Sum256([]byte("warpbox-delete:" + boxID + ":" + token))
return hex.EncodeToString(sum[:])
}
func previewKind(contentType string) string {
switch {
case strings.HasPrefix(contentType, "image/"):

View File

@@ -0,0 +1,124 @@
package services
import (
"bytes"
"io"
"log/slog"
"mime/multipart"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDeleteTokenVerification(t *testing.T) {
service := newTestUploadService(t)
result := createTestBox(t, service, "file.txt", "hello")
box := getTestBox(t, service, result.BoxID)
token := tokenFromManageURL(t, result.ManageURL)
if box.DeleteTokenHash == "" {
t.Fatalf("DeleteTokenHash was not stored")
}
if strings.Contains(box.DeleteTokenHash, token) {
t.Fatalf("DeleteTokenHash contains the raw token")
}
if !service.VerifyDeleteToken(box, token) {
t.Fatalf("VerifyDeleteToken rejected the correct token")
}
if service.VerifyDeleteToken(box, "wrong-token") {
t.Fatalf("VerifyDeleteToken accepted the wrong token")
}
}
func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
service := newTestUploadService(t)
result := createTestBox(t, service, "file.txt", "hello")
box := getTestBox(t, service, result.BoxID)
token := tokenFromManageURL(t, result.ManageURL)
if _, err := os.Stat(filepath.Join(service.filesDir, box.ID)); err != nil {
t.Fatalf("box files were not created: %v", err)
}
if err := service.DeleteBoxWithToken(box.ID, "wrong-token"); err == nil {
t.Fatalf("DeleteBoxWithToken accepted the wrong token")
}
if _, err := service.GetBox(box.ID); err != nil {
t.Fatalf("box was deleted after wrong token: %v", err)
}
if err := service.DeleteBoxWithToken(box.ID, token); err != nil {
t.Fatalf("DeleteBoxWithToken returned error: %v", err)
}
if _, err := service.GetBox(box.ID); !os.IsNotExist(err) {
t.Fatalf("GetBox after delete error = %v, want os.ErrNotExist", err)
}
if _, err := os.Stat(filepath.Join(service.filesDir, box.ID)); !os.IsNotExist(err) {
t.Fatalf("box directory still exists after delete: %v", err)
}
}
func newTestUploadService(t *testing.T) *UploadService {
t.Helper()
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
return service
}
func createTestBox(t *testing.T, service *UploadService, filename, body string) UploadResult {
t.Helper()
result, err := service.CreateBox(testFileHeaders(t, "file", filename, body), UploadOptions{MaxDays: 1})
if err != nil {
t.Fatalf("CreateBox returned error: %v", err)
}
return result
}
func getTestBox(t *testing.T, service *UploadService, boxID string) Box {
t.Helper()
box, err := service.GetBox(boxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
return box
}
func testFileHeaders(t *testing.T, field, filename, body string) []*multipart.FileHeader {
t.Helper()
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile returned error: %v", err)
}
if _, err := part.Write([]byte(body)); err != nil {
t.Fatalf("part.Write returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
request := httptest.NewRequest("POST", "/upload", &payload)
request.Header.Set("Content-Type", writer.FormDataContentType())
if err := request.ParseMultipartForm(1024 * 1024); err != nil {
t.Fatalf("ParseMultipartForm returned error: %v", err)
}
return request.MultipartForm.File[field]
}
func tokenFromManageURL(t *testing.T, manageURL string) string {
t.Helper()
parts := strings.Split(strings.TrimRight(manageURL, "/"), "/")
if len(parts) == 0 {
t.Fatalf("empty manage URL")
}
return parts[len(parts)-1]
}

View File

@@ -425,6 +425,18 @@ button {
gap: 0.5rem;
}
.manage-link {
margin: 0.9rem 0 0;
color: var(--muted-foreground);
font-size: 0.78rem;
text-align: left;
}
.manage-link a {
color: var(--foreground);
font-weight: 600;
}
.result-list,
.download-list {
display: grid;
@@ -457,7 +469,8 @@ button {
.result-item strong,
.download-item strong,
code {
.result-item code,
.download-item code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
@@ -483,7 +496,8 @@ code {
.result-item small,
.download-item small,
code {
.result-item code,
.download-item code {
display: block;
overflow: hidden;
text-overflow: ellipsis;
@@ -495,6 +509,24 @@ code {
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
color: var(--muted-foreground);
}
pre {
overflow-x: auto;
margin: 0.8rem 0 0;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--background);
padding: 0.9rem;
text-align: left;
}
pre code {
display: block;
margin: 0;
overflow: visible;
white-space: pre;
}
.download-view {
@@ -729,6 +761,39 @@ code {
gap: 0.75rem;
}
.manage-details {
display: grid;
gap: 0.5rem;
margin: 1rem 0 0;
text-align: left;
}
.manage-details div {
display: flex;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--border);
padding: 0.45rem 0;
}
.manage-details dt,
.manage-details dd {
margin: 0;
min-width: 0;
}
.manage-details dt {
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 600;
}
.manage-details dd {
color: var(--foreground);
font-size: 0.84rem;
text-align: right;
}
.preview-stage {
overflow: hidden;
margin-bottom: 1rem;
@@ -777,6 +842,105 @@ code {
padding: 2rem 0 3rem;
}
.docs-view {
width: min(72rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.docs-header {
max-width: 44rem;
}
.docs-header p {
margin: 0.55rem 0 0;
color: var(--muted-foreground);
line-height: 1.55;
}
.docs-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.docs-card {
box-shadow: none;
}
.docs-card h2 {
margin: 0;
font-size: 1rem;
}
.docs-card p {
margin: 0.65rem 0 0;
color: var(--muted-foreground);
font-size: 0.88rem;
line-height: 1.55;
}
.docs-card-wide {
grid-column: 1 / -1;
}
.endpoint-list,
.field-grid {
display: grid;
gap: 0.65rem;
margin: 1rem 0 0;
}
.endpoint-list div,
.field-grid {
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 {
color: var(--muted-foreground);
font-size: 0.78rem;
font-weight: 700;
}
.endpoint-list dd code {
display: block;
}
.docs-steps {
margin: 0.85rem 0 0;
padding-left: 1.1rem;
color: var(--muted-foreground);
font-size: 0.88rem;
line-height: 1.6;
}
.docs-steps li + li {
margin-top: 0.35rem;
}
.field-grid {
grid-template-columns: minmax(8rem, 0.35fr) minmax(0, 1fr);
}
.field-grid p {
margin: 0;
}
.admin-header,
.table-header {
display: flex;
@@ -892,6 +1056,16 @@ code {
grid-template-columns: 1fr;
}
.docs-grid,
.field-grid {
grid-template-columns: 1fr;
}
.endpoint-list div {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.result-actions {
width: 100%;
}

View File

@@ -12,6 +12,7 @@
const totalProgressBar = document.querySelector("#total-progress-bar");
const copyURL = document.querySelector("#copy-url");
const openBox = document.querySelector("#open-box");
const manageLink = document.querySelector("#manage-link");
const fileBrowser = document.querySelector("[data-file-browser]");
const viewButtons = document.querySelectorAll("[data-view-button]");
const previewImages = document.querySelector("[data-preview-images]");
@@ -228,6 +229,13 @@
result.hidden = false;
openBox.href = payload.boxUrl;
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
if (manageLink) {
const anchor = manageLink.querySelector("a");
manageLink.hidden = !payload.manageUrl;
if (anchor && payload.manageUrl) {
anchor.href = payload.manageUrl;
}
}
resultList.replaceChildren();
payload.files.forEach((file) => {

View File

@@ -27,7 +27,7 @@
<span>{{.AppName}}</span>
</a>
<div class="nav-links">
<a class="button button-ghost" href="/api/v1/health">API</a>
<a class="button button-ghost" href="/api">API</a>
<a class="button button-outline" href="/healthz">Health</a>
</div>
</nav>

View File

@@ -0,0 +1,96 @@
{{define "api.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="docs-view" aria-labelledby="api-title">
<div class="docs-header">
<p class="kicker">Developer docs</p>
<h1 id="api-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>
</div>
<div class="docs-grid">
<article class="card docs-card">
<div class="card-content">
<h2>Endpoints</h2>
<dl class="endpoint-list">
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
<div><dt>Health</dt><dd><code>GET /api/v1/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">
<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>
<p>For automation, request JSON to get file URLs and the private manage/delete URLs.</p>
<pre><code>curl -F file=@./report.pdf \
-H 'Accept: application/json' \
{{.Data.UploadURL}}</code></pre>
</div>
</article>
<article class="card docs-card">
<div class="card-content">
<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>
<pre><code>{
"boxId": "abc123",
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
"zipUrl": "{{.Data.BaseURL}}/d/abc123/zip",
"manageUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token",
"deleteUrl": "{{.Data.BaseURL}}/d/abc123/manage/private-token/delete",
"expiresAt": "2026-06-05T12:00:00Z",
"files": [
{
"id": "file123",
"name": "report.pdf",
"size": "2.4 MiB",
"url": "{{.Data.BaseURL}}/d/abc123/f/file123"
}
]
}</code></pre>
</div>
</article>
<article class="card docs-card">
<div class="card-content">
<h2>ShareX setup</h2>
<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>Or open the tracked template at <code>{{.Data.ShareXExamplePath}}</code> and change <code>RequestURL</code> to <code>{{.Data.ShareXExampleURL}}</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>
<pre><code>{
"RequestMethod": "POST",
"RequestURL": "{{.Data.ShareXExampleURL}}",
"Headers": { "Accept": "application/json" },
"Body": "MultipartFormData",
"FileFormName": "{{.Data.ShareXFileFieldName}}",
"URL": "$json:boxUrl$",
"DeletionURL": "$json:manageUrl$"
}</code></pre>
</div>
</article>
<article class="card docs-card docs-card-wide">
<div class="card-content">
<h2>Multipart fields</h2>
<div class="field-grid">
<span><code>file</code></span><p>One or more files for curl, browser, and generic multipart clients.</p>
<span><code>sharex</code></span><p>One or more files from ShareX custom uploader configs.</p>
<span><code>max_days</code></span><p>Optional number of days before expiration. Defaults to 7.</p>
<span><code>max_downloads</code></span><p>Optional download count limit.</p>
<span><code>password</code></span><p>Optional password required before viewing/downloading.</p>
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>; hides names/counts until unlock when a password is set.</p>
</div>
</div>
</article>
</div>
</section>
{{end}}

View File

@@ -80,6 +80,10 @@
</div>
</div>
<div class="result-list" id="result-list"></div>
<p class="manage-link" id="manage-link" hidden>
<span>Keep this private:</span>
<a href="/" target="_blank" rel="noopener noreferrer">Manage or delete this upload</a>
</p>
</div>
</section>
</section>

View File

@@ -0,0 +1,32 @@
{{define "manage.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view" aria-labelledby="manage-title">
<div class="card download-card">
<div class="card-content">
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v5" /><path d="M14 11v5" /></svg>
</div>
<h1 id="manage-title">Manage upload</h1>
<p class="download-subtitle">This private link can delete the entire box.</p>
<dl class="manage-details">
<div><dt>Box</dt><dd><code>{{.Data.Box.ID}}</code></dd></div>
<div><dt>Files</dt><dd>{{.Data.FileCount}}</dd></div>
<div><dt>Total size</dt><dd>{{.Data.TotalSize}}</dd></div>
<div><dt>Expires</dt><dd>{{.Data.ExpiresLabel}}</dd></div>
<div><dt>Downloads</dt><dd>{{.Data.DownloadCount}}{{if .Data.MaxDownloads}} / {{.Data.MaxDownloads}}{{end}}</dd></div>
<div><dt>Protected</dt><dd>{{if .Data.Protected}}Yes{{else}}No{{end}}</dd></div>
</dl>
<form action="{{.Data.DeleteActionURL}}" method="post">
<button class="button button-danger button-wide" type="submit">
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /></svg>
Delete upload
</button>
</form>
<a class="button button-outline button-wide" href="/d/{{.Data.Box.ID}}">Back to box</a>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,16 @@
{{define "manage_deleted.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="download-view" aria-labelledby="deleted-title">
<div class="card download-card">
<div class="card-content">
<div class="file-emblem" aria-hidden="true">
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M20 6 9 17l-5-5" /></svg>
</div>
<h1 id="deleted-title">Upload deleted</h1>
<p class="download-subtitle">Box <code>{{.Data.ID}}</code> and all of its files have been removed.</p>
<a class="button button-primary button-wide" href="/">Upload another file</a>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,14 @@
{
"Version": "18.0.0",
"Name": "Warpbox Anonymous Upload",
"DestinationType": "ImageUploader, TextUploader, FileUploader",
"RequestMethod": "POST",
"RequestURL": "https://warpbox.dev/api/v1/upload",
"Headers": {
"Accept": "application/json"
},
"Body": "MultipartFormData",
"FileFormName": "sharex",
"URL": "$json:boxUrl$",
"DeletionURL": "$json:manageUrl$"
}

View File

@@ -9,7 +9,7 @@ WARPBOX_CLEANUP_ENABLED=true
WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_ENABLED=true
WARPBOX_THUMBNAIL_EVERY=1m
WARPBOX_MAX_UPLOAD_SIZE_MB=2048
WARPBOX_MAX_UPLOAD_SIZE_MB=16384
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s