Endpoints
+-
+
- Upload
POST /api/v1/upload- Health
GET /api/v1/health- Request schema
/api/v1/schemas/upload-request.json- Response schema
/api/v1/schemas/upload-response.json
diff --git a/README.md b/README.md index 9888758..c46bc3d 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/backend/libs/handlers/api_docs.go b/backend/libs/handlers/api_docs.go new file mode 100644 index 0000000..1e19fda --- /dev/null +++ b/backend/libs/handlers/api_docs.go @@ -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"}, + }, + }, + }, + }, + }) +} diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 4fa9283..a2a375b 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -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()) } diff --git a/backend/libs/handlers/manage.go b/backend/libs/handlers/manage.go new file mode 100644 index 0000000..dd5703f --- /dev/null +++ b/backend/libs/handlers/manage.go @@ -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", + } +} diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 540a311..3445f90 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -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) - helpers.WriteJSON(w, http.StatusCreated, result) + 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") +} diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go new file mode 100644 index 0000000..471e353 --- /dev/null +++ b/backend/libs/handlers/upload_stage3_test.go @@ -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) + }) +} diff --git a/backend/libs/jobs/thumbnails.go b/backend/libs/jobs/thumbnails.go index 891f3a9..987f9cf 100644 --- a/backend/libs/jobs/thumbnails.go +++ b/backend/libs/jobs/thumbnails.go @@ -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,40 +73,56 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl continue } - changed := false - for i := range box.Files { - file := &box.Files[i] - if file.Thumbnail != "" || !needsThumbnail(*file) { - continue - } - result.Scanned++ - - thumbnail, err := generateThumbnail(uploadService, box, *file) - if err != nil { - logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) - result.Failed++ - continue - } - if thumbnail == "" { - result.Failed++ - continue - } - - file.Thumbnail = thumbnail - changed = true - result.Generated++ - } - - if changed { - if err := uploadService.SaveBox(box); err != nil { - return result, err - } + 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] + if file.Thumbnail != "" || !needsThumbnail(*file) { + continue + } + result.Scanned++ + + thumbnail, err := generateThumbnail(uploadService, box, *file) + if err != nil { + logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) + result.Failed++ + continue + } + if thumbnail == "" { + result.Failed++ + continue + } + + file.Thumbnail = thumbnail + changed = true + result.Generated++ + } + + if changed { + if err := uploadService.SaveBox(box); err != nil { + return result, err + } + } + return result, nil +} + func needsThumbnail(file services.File) bool { return file.PreviewKind == "image" || file.PreviewKind == "video" } diff --git a/backend/libs/jobs/thumbnails_test.go b/backend/libs/jobs/thumbnails_test.go new file mode 100644 index 0000000..6e061ae --- /dev/null +++ b/backend/libs/jobs/thumbnails_test.go @@ -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"] +} diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index f32341d..48bf38a 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -40,15 +40,16 @@ type UploadOptions struct { } type Box struct { - ID string `json:"id"` - CreatedAt time.Time `json:"createdAt"` - ExpiresAt time.Time `json:"expiresAt"` - MaxDownloads int `json:"maxDownloads"` - DownloadCount int `json:"downloadCount"` - PasswordSalt string `json:"passwordSalt,omitempty"` - PasswordHash string `json:"passwordHash,omitempty"` - Obfuscate bool `json:"obfuscate"` - Files []File `json:"files"` + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + ExpiresAt time.Time `json:"expiresAt"` + MaxDownloads int `json:"maxDownloads"` + 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"` } type File struct { @@ -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/"): diff --git a/backend/libs/services/upload_test.go b/backend/libs/services/upload_test.go new file mode 100644 index 0000000..9c1374f --- /dev/null +++ b/backend/libs/services/upload_test.go @@ -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] +} diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 6302c1f..eccc411 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -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%; } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3e3f038..ddba999 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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) => { diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 06a564a..fcc05a6 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -27,7 +27,7 @@ {{.AppName}}
diff --git a/backend/templates/pages/api.html b/backend/templates/pages/api.html new file mode 100644 index 0000000..c2b4c40 --- /dev/null +++ b/backend/templates/pages/api.html @@ -0,0 +1,96 @@ +{{define "api.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +Developer docs
+Anonymous uploads for curl, scripts, and ShareX. The upload endpoint accepts multipart files and returns either plain text or JSON based on the Accept header.
POST /api/v1/uploadGET /api/v1/health/api/v1/schemas/upload-request.json/api/v1/schemas/upload-response.jsonWithout a JSON Accept header, Warpbox prints one plain box URL for shell-friendly usage.
curl -F file=@./report.pdf {{.Data.UploadURL}}
+ For automation, request JSON to get file URLs and the private manage/delete URLs.
+curl -F file=@./report.pdf \
+ -H 'Accept: application/json' \
+ {{.Data.UploadURL}}
+ The raw delete token is returned only once inside manageUrl and deleteUrl. Keep those links private.
{
+ "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"
+ }
+ ]
+}
+ /api/v1/sharex/warpbox-anonymous.sxcu.{{.Data.ShareXExamplePath}} and change RequestURL to {{.Data.ShareXExampleURL}}.FileFormName as {{.Data.ShareXFileFieldName}}..sxcu file into ShareX as a custom uploader.boxUrl as the public URL and manageUrl as the deletion URL.{
+ "RequestMethod": "POST",
+ "RequestURL": "{{.Data.ShareXExampleURL}}",
+ "Headers": { "Accept": "application/json" },
+ "Body": "MultipartFormData",
+ "FileFormName": "{{.Data.ShareXFileFieldName}}",
+ "URL": "$json:boxUrl$",
+ "DeletionURL": "$json:manageUrl$"
+}
+ fileOne or more files for curl, browser, and generic multipart clients.
+sharexOne or more files from ShareX custom uploader configs.
+max_daysOptional number of days before expiration. Defaults to 7.
+max_downloadsOptional download count limit.
+passwordOptional password required before viewing/downloading.
+obfuscate_metadataOptional on; hides names/counts until unlock when a password is set.
+ Keep this private: + Manage or delete this upload +
diff --git a/backend/templates/pages/manage.html b/backend/templates/pages/manage.html new file mode 100644 index 0000000..7b65f02 --- /dev/null +++ b/backend/templates/pages/manage.html @@ -0,0 +1,32 @@ +{{define "manage.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +This private link can delete the entire box.
+ +{{.Data.Box.ID}}Box {{.Data.ID}} and all of its files have been removed.