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:
133
backend/libs/handlers/api_docs.go
Normal file
133
backend/libs/handlers/api_docs.go
Normal 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
87
backend/libs/handlers/manage.go
Normal file
87
backend/libs/handlers/manage.go
Normal 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",
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
268
backend/libs/handlers/upload_stage3_test.go
Normal file
268
backend/libs/handlers/upload_stage3_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
103
backend/libs/jobs/thumbnails_test.go
Normal file
103
backend/libs/jobs/thumbnails_test.go
Normal 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"]
|
||||
}
|
||||
@@ -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/"):
|
||||
|
||||
124
backend/libs/services/upload_test.go
Normal file
124
backend/libs/services/upload_test.go
Normal 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]
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
backend/templates/pages/api.html
Normal file
96
backend/templates/pages/api.html
Normal 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}}
|
||||
@@ -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>
|
||||
|
||||
32
backend/templates/pages/manage.html
Normal file
32
backend/templates/pages/manage.html
Normal 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}}
|
||||
16
backend/templates/pages/manage_deleted.html
Normal file
16
backend/templates/pages/manage_deleted.html
Normal 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}}
|
||||
Reference in New Issue
Block a user