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]
|
||||
}
|
||||
Reference in New Issue
Block a user