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:
@@ -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())
|
||||
}
|
||||
|
||||
328
backend/libs/handlers/resumable.go
Normal file
328
backend/libs/handlers/resumable.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user