feat(api): add API documentation and ShareX integration

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,14 @@ package handlers
import (
"errors"
"fmt"
"mime/multipart"
"net/http"
"strconv"
"strings"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
)
@@ -16,7 +20,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
files := r.MultipartForm.File["file"]
files := uploadFiles(r)
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")),
@@ -28,8 +32,16 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
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")
}

View File

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

View File

@@ -23,6 +23,25 @@ type thumbnailJobResult struct {
Failed int
}
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
go func() {
box, err := uploadService.GetBox(boxID)
if err != nil {
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
return
}
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
if err != nil {
logger.Warn("thumbnail one-shot job failed", "source", "thumbnail", "severity", "warn", "code", 4205, "box_id", boxID, "error", err.Error())
return
}
if result.Generated > 0 || result.Failed > 0 {
logger.Info("thumbnail one-shot job complete", "source", "thumbnail", "severity", "user_activity", "code", 2205, "box_id", boxID, "generated", result.Generated, "failed", result.Failed)
}
}()
}
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
return job{
name: "thumbnail",
@@ -54,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"
}

View File

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

View File

@@ -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/"):

View File

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