package handlers import ( "context" "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"` ResumeToken string `json:"resumeToken,omitempty"` 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) { 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 !settings.ResumableUploadsEnabled { helpers.WriteJSONError(w, http.StatusForbidden, "resumable uploads are disabled") 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 } chunkSize := int64(settings.ResumableChunkSizeMB * 1024 * 1024) retention := time.Duration(settings.ResumableRetentionHours) * time.Hour session, err := a.uploadService.CreateResumableSession(payload.Files, opts, chunkSize, retention, resumableChunkRoot(settings)) 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 } if session.Status == services.ResumableStatusCompleted || session.Status == services.ResumableStatusProcessing { result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID) if err != nil { a.logger.Warn("resumable upload completion replay 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 } a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", completed.ID, "box_id", result.BoxID, "files", len(result.Files))...) helpers.WriteJSON(w, http.StatusOK, result) 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.CreateProcessingBoxFromResumable(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()) } } a.finalizeResumableUploadAsync(completed.ID, result.BoxID) a.logger.Info("resumable upload queued for processing", 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) CompleteUploadedResumableUpload(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 var completeCount int for _, file := range session.Files { if len(file.UploadedChunks) != file.ChunkCount { continue } fileSizes = append(fileSizes, file.Size) totalBytes += file.Size completeCount++ } if completeCount == 0 { helpers.WriteJSONError(w, http.StatusBadRequest, "no fully uploaded files to finish") return } 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.CompleteUploadedResumableSession(r.Context(), session.ID) if err != nil { a.logger.Warn("resumable partial complete failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4005, "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 partial resumable upload usage", "source", "quota", "severity", "warn", "code", 4406, "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 uploaded files completed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2007, "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) finalizeResumableUploadAsync(sessionID, boxID string) { go func() { a.logger.Info("resumable upload processing started", "source", "user-upload", "severity", "user_activity", "code", 2009, "session_id", sessionID, "box_id", boxID) result, err := a.uploadService.FinalizeProcessingResumableSession(context.Background(), sessionID) if err != nil { a.logger.Warn("resumable upload processing failed", "source", "user-upload", "severity", "warn", "code", 4010, "session_id", sessionID, "box_id", boxID, "error", err.Error()) return } jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID) a.logger.Info("resumable upload processing completed", "source", "user-upload", "severity", "user_activity", "code", 2010, "session_id", sessionID, "box_id", result.BoxID, "files", len(result.Files)) }() } func resumableChunkRoot(settings services.UploadPolicySettings) string { if settings.ResumableChunkMode != "custom" { return "" } return strings.TrimSpace(settings.ResumableChunkPath) } 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 !a.uploadService.VerifyResumableToken(session, r.Header.Get("X-Warpbox-Resume-Token")) { helpers.WriteJSONError(w, http.StatusUnauthorized, "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, ResumeToken: session.ResumeToken, ChunkSize: session.ChunkSize, Status: session.Status, BoxID: session.BoxID, ExpiresAt: session.ExpiresAt.Format(time.RFC3339), Files: session.Files, } }