feat(uploads): add native resumable upload support

Implement a native chunked resumable upload API and frontend integration
to support reliable large file uploads.

Changes include:
- Added a 3-step resumable upload API flow (create session, upload chunks, complete session).
- Introduced configuration options for chunk size, retention hours, and toggling the feature.
- Updated the frontend to utilize resumable uploads with progress tracking.
- Configured temporary chunk storage under `data/tmp/uploads` with automatic cleanup.
- Documented the API flow and configuration in the README.
This commit is contained in:
2026-06-02 17:41:41 +03:00
parent d3b6a86753
commit 5cd476e7f3
16 changed files with 1805 additions and 85 deletions

View File

@@ -141,6 +141,12 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
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.HandleFunc("POST /api/v1/uploads/resumable", a.CreateResumableUpload)
mux.HandleFunc("GET /api/v1/uploads/resumable/{sessionID}", a.ResumableUploadStatus)
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/files", a.AddResumableFiles)
mux.HandleFunc("PUT /api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}", a.PutResumableChunk)
mux.HandleFunc("POST /api/v1/uploads/resumable/{sessionID}/complete", a.CompleteResumableUpload)
mux.HandleFunc("DELETE /api/v1/uploads/resumable/{sessionID}", a.CancelResumableUpload)
mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset)
mux.Handle("GET /static/", a.Static())
}

View File

@@ -0,0 +1,328 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
)
type resumableCreateRequest struct {
Files []services.ResumableFileInput `json:"files"`
MaxDays int `json:"maxDays"`
ExpiresMinutes int `json:"expiresMinutes"`
MaxDownloads int `json:"maxDownloads"`
Password string `json:"password"`
ObfuscateMetadata bool `json:"obfuscateMetadata"`
CollectionID string `json:"collectionId"`
}
type resumableSessionResponse struct {
SessionID string `json:"sessionId"`
ChunkSize int64 `json:"chunkSize"`
Status string `json:"status"`
BoxID string `json:"boxId,omitempty"`
ExpiresAt string `json:"expiresAt"`
Files []services.ResumableFile `json:"files"`
}
func (a *App) CreateResumableUpload(w http.ResponseWriter, r *http.Request) {
if !a.cfg.ResumableUploadsEnabled {
helpers.WriteJSONError(w, http.StatusForbidden, "resumable uploads are disabled")
return
}
user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil {
a.logger.Warn("resumable upload rejected invalid bearer token", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4011)...)
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return
}
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
if !loggedIn && !settings.AnonymousUploadsEnabled {
a.logger.Warn("resumable anonymous upload rejected disabled", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4013)...)
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && policy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, policy.ShortRequests, policy.ShortWindow, time.Now().UTC()) {
a.logger.Warn("resumable upload rate limited", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4291, "user_id", user.ID)...)
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return
}
var payload resumableCreateRequest
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "upload session request could not be read")
return
}
fileSizes := make([]int64, 0, len(payload.Files))
var totalBytes int64
for _, file := range payload.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
a.logger.Warn("resumable upload rejected by policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(payload.Files))...)
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
a.logger.Warn("resumable upload rejected by box policy", withRequestLogAttrs(r, "source", "quota", "severity", "warn", "code", status, "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(payload.Files))...)
helpers.WriteJSONError(w, status, message)
return
}
}
opts, err := a.resumableUploadOptions(r, payload, user, loggedIn, isAdminUpload, policy)
if err != nil {
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
return
}
session, err := a.uploadService.CreateResumableSession(payload.Files, opts, a.cfg.ResumableChunkSize, a.cfg.ResumableRetention)
if err != nil {
a.logger.Warn("resumable session create failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4002, "user_id", user.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload session created", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2002, "user_id", user.ID, "session_id", session.ID, "files", len(session.Files), "bytes", totalBytes, "anonymous", !loggedIn)...)
helpers.WriteJSON(w, http.StatusCreated, resumableResponse(session))
}
func (a *App) ResumableUploadStatus(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
helpers.WriteJSON(w, http.StatusOK, resumableResponse(session))
}
func (a *App) AddResumableFiles(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
var payload struct {
Files []services.ResumableFileInput `json:"files"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "upload files request could not be read")
return
}
fileSizes := make([]int64, 0, len(session.Files)+len(payload.Files))
var totalBytes int64
for _, file := range session.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
for _, file := range payload.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
updated, err := a.uploadService.AddResumableFiles(session.ID, payload.Files)
if err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload files added", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2006, "session_id", session.ID, "added", len(updated.Files)-len(session.Files), "files", len(updated.Files))...)
helpers.WriteJSON(w, http.StatusOK, resumableResponse(updated))
}
func (a *App) PutResumableChunk(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
fileID := r.PathValue("fileID")
index, err := strconv.Atoi(r.PathValue("index"))
if err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "chunk index is invalid")
return
}
updated, err := a.uploadService.PutResumableChunk(r.Context(), session.ID, fileID, index, r.Body)
if err != nil {
a.logger.Warn("resumable chunk failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4003, "session_id", session.ID, "file_id", fileID, "chunk", index, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable chunk uploaded", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2003, "session_id", session.ID, "file_id", fileID, "chunk", index)...)
helpers.WriteJSON(w, http.StatusOK, resumableResponse(updated))
}
func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
user, loggedIn, _ := a.currentUserWithAuthError(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
if !ok {
return
}
fileSizes := make([]int64, 0, len(session.Files))
var totalBytes int64
for _, file := range session.Files {
fileSizes = append(fileSizes, file.Size)
totalBytes += file.Size
}
if !isAdminUpload {
if status, message := a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, fileSizes, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkBoxCreationPolicy(r, user, loggedIn, policy); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
if status, message := a.checkStorageBackendCapacity(session.Options.StorageBackendID, settings, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID)
if err != nil {
a.logger.Warn("resumable upload complete failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
a.logger.Warn("failed to record resumable upload usage", "source", "quota", "severity", "warn", "code", 4404, "error", err.Error())
}
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4405, "error", err.Error())
}
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
a.logger.Info("resumable upload completed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "user_id", user.ID, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files), "bytes", totalBytes, "admin", isAdminUpload, "anonymous", !loggedIn)...)
helpers.WriteJSON(w, http.StatusCreated, result)
}
func (a *App) CancelResumableUpload(w http.ResponseWriter, r *http.Request) {
session, ok := a.authorizedResumableSession(w, r)
if !ok {
return
}
if err := a.uploadService.CancelResumableSession(session.ID); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
a.logger.Info("resumable upload cancelled", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2005, "session_id", session.ID)...)
w.WriteHeader(http.StatusNoContent)
}
func (a *App) authorizedResumableSession(w http.ResponseWriter, r *http.Request) (services.ResumableSession, bool) {
user, loggedIn, authErr := a.currentUserWithAuthError(r)
if authErr != nil {
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
return services.ResumableSession{}, false
}
session, err := a.uploadService.GetResumableSession(r.PathValue("sessionID"))
if err != nil {
helpers.WriteJSONError(w, http.StatusNotFound, "upload session not found")
return services.ResumableSession{}, false
}
if loggedIn {
if session.Options.OwnerID != user.ID {
helpers.WriteJSONError(w, http.StatusForbidden, "upload session not found")
return services.ResumableSession{}, false
}
return session, true
}
if session.Options.OwnerID != "" || session.Options.CreatorIP != uploadClientIP(r) {
helpers.WriteJSONError(w, http.StatusForbidden, "upload session not found")
return services.ResumableSession{}, false
}
return session, true
}
func (a *App) loadUploadPolicyForAPI(w http.ResponseWriter, r *http.Request, user services.User, loggedIn bool) (services.UploadPolicySettings, services.EffectiveUploadPolicy, bool) {
settings, err := a.settingsService.UploadPolicy()
if err != nil {
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5006, "error", err.Error())
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
return services.UploadPolicySettings{}, services.EffectiveUploadPolicy{}, false
}
return settings, a.effectiveUploadPolicy(settings, user, loggedIn), true
}
func (a *App) resumableUploadOptions(r *http.Request, payload resumableCreateRequest, user services.User, loggedIn, isAdminUpload bool, policy services.EffectiveUploadPolicy) (services.UploadOptions, error) {
var ownerID string
var collectionID string
if loggedIn {
ownerID = user.ID
collectionID = strings.TrimSpace(payload.CollectionID)
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
return services.UploadOptions{}, fmt.Errorf("collection not found")
}
}
unlimitedExpiry := isAdminUpload || policy.MaxDays < 0
rawMaxDays := payload.MaxDays
maxDays := rawMaxDays
if maxDays <= 0 {
maxDays = 7
if policy.MaxDays > 0 && policy.MaxDays < maxDays {
maxDays = policy.MaxDays
}
}
expiresMinutes := payload.ExpiresMinutes
if expiresMinutes < 0 || rawMaxDays < 0 {
if !unlimitedExpiry {
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
}
expiresMinutes = -1
} else if !unlimitedExpiry {
if maxDays > policy.MaxDays {
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
}
if expiresMinutes > 0 && expiresMinutes > policy.MaxDays*24*60 {
return services.UploadOptions{}, fmt.Errorf("expiration cannot exceed %d days", policy.MaxDays)
}
}
return services.UploadOptions{
MaxDays: maxDays,
ExpiresInMinutes: expiresMinutes,
MaxDownloads: payload.MaxDownloads,
Password: payload.Password,
ObfuscateMetadata: payload.ObfuscateMetadata,
OwnerID: ownerID,
CollectionID: collectionID,
SkipSizeLimit: isAdminUpload || policy.MaxUploadMB < 0,
CreatorIP: uploadClientIP(r),
StorageBackendID: policy.StorageBackendID,
}, nil
}
func resumableResponse(session services.ResumableSession) resumableSessionResponse {
return resumableSessionResponse{
SessionID: session.ID,
ChunkSize: session.ChunkSize,
Status: session.Status,
BoxID: session.BoxID,
ExpiresAt: session.ExpiresAt.Format(time.RFC3339),
Files: session.Files,
}
}

View File

@@ -228,11 +228,22 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if len(files) == 0 {
return 0, ""
}
sizes := make([]int64, 0, len(files))
for _, file := range files {
sizes = append(sizes, file.Size)
}
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
}
func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, fileSizes []int64, totalBytes int64) (int, string) {
if len(fileSizes) == 0 {
return 0, ""
}
now := time.Now().UTC()
if policy.MaxUploadMB > 0 {
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, file := range files {
if file.Size > maxBytes {
for _, fileSize := range fileSizes {
if fileSize > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
}
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
@@ -10,8 +11,10 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/services"
@@ -103,6 +106,196 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
}
}
func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createBody := `{"files":[{"name":"note.txt","size":11,"contentType":"text/plain"}],"expiresMinutes":60}`
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
createRequest.Header.Set("Accept", "application/json")
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
ChunkSize int64 `json:"chunkSize"`
Files []struct {
ID string `json:"id"`
ChunkCount int `json:"chunkCount"`
UploadedChunks []int `json:"uploadedChunks"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
if session.SessionID == "" || session.ChunkSize != 4 || len(session.Files) != 1 || session.Files[0].ChunkCount != 3 {
t.Fatalf("unexpected session response: %+v", session)
}
chunks := map[int]string{1: "o wo", 0: "hell", 2: "rld"}
for index, body := range chunks {
request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/"+strconv.Itoa(index), strings.NewReader(body))
request.SetPathValue("sessionID", session.SessionID)
request.SetPathValue("fileID", session.Files[0].ID)
request.SetPathValue("index", strconv.Itoa(index))
response := httptest.NewRecorder()
app.PutResumableChunk(response, request)
if response.Code != http.StatusOK {
t.Fatalf("chunk %d status = %d, body = %s", index, response.Code, response.Body.String())
}
}
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
completeRequest.SetPathValue("sessionID", session.SessionID)
completeResponse := httptest.NewRecorder()
app.CompleteResumableUpload(completeResponse, completeRequest)
if completeResponse.Code != http.StatusCreated {
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal result returned error: %v", err)
}
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
if len(box.Files) != 1 || box.Files[0].Name != "note.txt" || box.Files[0].Size != 11 {
t.Fatalf("unexpected box files: %+v", box.Files)
}
object, err := app.uploadService.OpenFileObject(context.Background(), box, box.Files[0])
if err != nil {
t.Fatalf("OpenFileObject returned error: %v", err)
}
data, err := io.ReadAll(object.Body)
object.Body.Close()
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if string(data) != "hello world" {
t.Fatalf("uploaded body = %q", string(data))
}
}
func TestResumableUploadRequiresAllChunks(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":8,"contentType":"text/plain"}]}`))
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
chunkRequest := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("hell"))
chunkRequest.SetPathValue("sessionID", session.SessionID)
chunkRequest.SetPathValue("fileID", session.Files[0].ID)
chunkRequest.SetPathValue("index", "0")
chunkResponse := httptest.NewRecorder()
app.PutResumableChunk(chunkResponse, chunkRequest)
if chunkResponse.Code != http.StatusOK {
t.Fatalf("chunk status = %d, body = %s", chunkResponse.Code, chunkResponse.Body.String())
}
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
completeRequest.SetPathValue("sessionID", session.SessionID)
completeResponse := httptest.NewRecorder()
app.CompleteResumableUpload(completeResponse, completeRequest)
if completeResponse.Code != http.StatusBadRequest {
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
}
func TestResumableUploadCanAddFilesToExistingSession(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
createBody := `{"files":[{"name":"one.txt","size":4,"contentType":"text/plain","fingerprint":"one"}],"expiresMinutes":60}`
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
createResponse := httptest.NewRecorder()
app.CreateResumableUpload(createResponse, createRequest)
if createResponse.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
}
var session struct {
SessionID string `json:"sessionId"`
Files []struct {
ID string `json:"id"`
} `json:"files"`
}
if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
t.Fatalf("json.Unmarshal session returned error: %v", err)
}
firstChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("one!"))
firstChunk.SetPathValue("sessionID", session.SessionID)
firstChunk.SetPathValue("fileID", session.Files[0].ID)
firstChunk.SetPathValue("index", "0")
firstChunkResponse := httptest.NewRecorder()
app.PutResumableChunk(firstChunkResponse, firstChunk)
if firstChunkResponse.Code != http.StatusOK {
t.Fatalf("first chunk status = %d, body = %s", firstChunkResponse.Code, firstChunkResponse.Body.String())
}
addRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/files", strings.NewReader(`{"files":[{"name":"two.txt","size":4,"contentType":"text/plain","fingerprint":"two"}]}`))
addRequest.SetPathValue("sessionID", session.SessionID)
addResponse := httptest.NewRecorder()
app.AddResumableFiles(addResponse, addRequest)
if addResponse.Code != http.StatusOK {
t.Fatalf("add status = %d, body = %s", addResponse.Code, addResponse.Body.String())
}
var updated struct {
Files []struct {
ID string `json:"id"`
Name string `json:"name"`
UploadedChunks []int `json:"uploadedChunks"`
} `json:"files"`
}
if err := json.Unmarshal(addResponse.Body.Bytes(), &updated); err != nil {
t.Fatalf("json.Unmarshal updated returned error: %v", err)
}
if len(updated.Files) != 2 || len(updated.Files[0].UploadedChunks) != 1 {
t.Fatalf("unexpected updated session: %+v", updated)
}
secondChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+updated.Files[1].ID+"/chunks/0", strings.NewReader("two!"))
secondChunk.SetPathValue("sessionID", session.SessionID)
secondChunk.SetPathValue("fileID", updated.Files[1].ID)
secondChunk.SetPathValue("index", "0")
secondChunkResponse := httptest.NewRecorder()
app.PutResumableChunk(secondChunkResponse, secondChunk)
if secondChunkResponse.Code != http.StatusOK {
t.Fatalf("second chunk status = %d, body = %s", secondChunkResponse.Code, secondChunkResponse.Body.String())
}
completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
completeRequest.SetPathValue("sessionID", session.SessionID)
completeResponse := httptest.NewRecorder()
app.CompleteResumableUpload(completeResponse, completeRequest)
if completeResponse.Code != http.StatusCreated {
t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal result returned error: %v", err)
}
box, err := app.uploadService.GetBox(payload.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
if len(box.Files) != 2 {
t.Fatalf("box file count = %d, want 2", len(box.Files))
}
}
func TestManageBoxAndDeleteFlow(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
@@ -214,13 +407,16 @@ func newTestApp(t *testing.T) (*App, func()) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{
AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024,
AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024,
ResumableUploadsEnabled: true,
ResumableChunkSize: 4,
ResumableRetention: time.Hour,
DefaultSettings: config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 1,