2026-05-25 15:36:49 +03:00
package handlers
import (
2026-05-31 02:14:10 +03:00
"context"
2026-05-25 16:26:47 +03:00
"errors"
2026-05-29 23:44:05 +03:00
"fmt"
"mime/multipart"
2026-05-25 15:36:49 +03:00
"net/http"
2026-05-25 16:26:47 +03:00
"strconv"
2026-05-29 23:44:05 +03:00
"strings"
2026-05-30 17:23:20 +03:00
"time"
2026-05-25 15:36:49 +03:00
"warpbox.dev/backend/libs/helpers"
2026-05-29 23:44:05 +03:00
"warpbox.dev/backend/libs/jobs"
2026-05-25 16:26:47 +03:00
"warpbox.dev/backend/libs/services"
2026-05-25 15:36:49 +03:00
)
2026-05-25 16:26:47 +03:00
func ( a * App ) Upload ( w http . ResponseWriter , r * http . Request ) {
2026-05-31 13:02:58 +03:00
user , loggedIn , authErr := a . currentUserWithAuthError ( r )
if authErr != nil {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload rejected invalid bearer token" , "source" , "user-upload" , "severity" , "warn" , "code" , 4010 , "ip" , uploadClientIP ( r ) , "user_agent" , r . UserAgent ( ) )
2026-05-31 13:02:58 +03:00
helpers . WriteJSONError ( w , http . StatusUnauthorized , "invalid bearer token" )
return
}
2026-05-30 15:42:35 +03:00
isAdminUpload := loggedIn && user . Role == services . UserRoleAdmin
2026-05-30 17:23:20 +03:00
settings , err := a . settingsService . UploadPolicy ( )
if err != nil {
a . logger . Error ( "failed to load upload policy" , "source" , "settings" , "severity" , "error" , "code" , 5005 , "error" , err . Error ( ) )
helpers . WriteJSONError ( w , http . StatusInternalServerError , "upload policy could not be loaded" )
return
}
if ! loggedIn && ! settings . AnonymousUploadsEnabled {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "anonymous upload rejected disabled" , "source" , "user-upload" , "severity" , "warn" , "code" , 4012 , "ip" , uploadClientIP ( r ) )
2026-05-30 17:23:20 +03:00
helpers . WriteJSONError ( w , http . StatusForbidden , "anonymous uploads are disabled" )
return
}
2026-05-31 02:14:10 +03:00
effectivePolicy := a . effectiveUploadPolicy ( settings , user , loggedIn )
rateKey := uploadRateKey ( r , user , loggedIn )
if ! isAdminUpload && ! a . rateLimiter . Allow ( "upload:" + rateKey , effectivePolicy . ShortRequests , effectivePolicy . ShortWindow , time . Now ( ) . UTC ( ) ) {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload rate limited" , "source" , "user-upload" , "severity" , "warn" , "code" , 4290 , "ip" , uploadClientIP ( r ) , "user_id" , user . ID )
2026-05-31 02:14:10 +03:00
helpers . WriteJSONError ( w , http . StatusTooManyRequests , "too many upload requests, please slow down" )
return
}
2026-05-30 17:23:20 +03:00
2026-05-31 02:14:10 +03:00
parseLimit := uploadParseLimit ( effectivePolicy , loggedIn , a . uploadService . MaxUploadSize ( ) )
2026-05-31 14:01:38 +03:00
if ! isAdminUpload && parseLimit > 0 {
r . Body = http . MaxBytesReader ( w , r . Body , parseLimit )
}
2026-05-30 15:42:35 +03:00
if isAdminUpload {
parseLimit = 32 << 20
2026-05-31 14:01:38 +03:00
} else if parseLimit <= 0 {
parseLimit = 32 << 20
2026-05-30 15:42:35 +03:00
}
if err := r . ParseMultipartForm ( parseLimit ) ; err != nil {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload form parse failed" , "source" , "user-upload" , "severity" , "warn" , "code" , 4000 , "ip" , uploadClientIP ( r ) , "user_id" , user . ID , "error" , err . Error ( ) )
2026-05-25 16:26:47 +03:00
helpers . WriteJSONError ( w , http . StatusBadRequest , "upload form could not be read" )
return
}
2026-05-25 15:36:49 +03:00
2026-05-29 23:44:05 +03:00
files := uploadFiles ( r )
2026-05-30 17:23:20 +03:00
totalBytes := totalUploadBytes ( files )
2026-05-30 15:42:35 +03:00
var ownerID string
var collectionID string
if loggedIn {
ownerID = user . ID
collectionID = r . FormValue ( "collection_id" )
if ! a . authService . CollectionOwnedBy ( collectionID , user . ID ) {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload rejected invalid collection" , "source" , "user-upload" , "severity" , "warn" , "code" , 4030 , "user_id" , user . ID , "collection_id" , collectionID )
2026-05-30 15:42:35 +03:00
helpers . WriteJSONError ( w , http . StatusForbidden , "collection not found" )
return
}
}
2026-05-30 17:23:20 +03:00
if ! isAdminUpload {
2026-05-31 02:14:10 +03:00
if status , message := a . checkUploadPolicy ( r , user , loggedIn , settings , effectivePolicy , files , totalBytes ) ; message != "" {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload rejected by policy" , "source" , "quota" , "severity" , "warn" , "code" , status , "ip" , uploadClientIP ( r ) , "user_id" , user . ID , "message" , message , "bytes" , totalBytes , "files" , len ( files ) )
2026-05-30 17:23:20 +03:00
helpers . WriteJSONError ( w , status , message )
return
}
}
2026-05-31 02:14:10 +03:00
maxDays := parseInt ( r . FormValue ( "max_days" ) )
if maxDays <= 0 {
maxDays = min ( 7 , effectivePolicy . MaxDays )
}
if ! isAdminUpload && maxDays > effectivePolicy . MaxDays {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload rejected expiration days" , "source" , "user-upload" , "severity" , "warn" , "code" , 4131 , "ip" , uploadClientIP ( r ) , "user_id" , user . ID , "requested_days" , maxDays , "max_days" , effectivePolicy . MaxDays )
2026-05-31 02:14:10 +03:00
helpers . WriteJSONError ( w , http . StatusRequestEntityTooLarge , fmt . Sprintf ( "expiration cannot exceed %d days" , effectivePolicy . MaxDays ) )
return
}
2026-05-31 15:30:53 +03:00
expiresMinutes := parseInt ( r . FormValue ( "expires_minutes" ) )
if expiresMinutes > 0 && ! isAdminUpload && expiresMinutes > effectivePolicy . MaxDays * 24 * 60 {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload rejected expiration minutes" , "source" , "user-upload" , "severity" , "warn" , "code" , 4132 , "ip" , uploadClientIP ( r ) , "user_id" , user . ID , "requested_minutes" , expiresMinutes , "max_days" , effectivePolicy . MaxDays )
2026-05-31 15:30:53 +03:00
helpers . WriteJSONError ( w , http . StatusRequestEntityTooLarge , fmt . Sprintf ( "expiration cannot exceed %d days" , effectivePolicy . MaxDays ) )
return
}
2026-05-31 22:27:43 +03:00
opts := services . UploadOptions {
2026-05-31 02:14:10 +03:00
MaxDays : maxDays ,
2026-05-31 15:30:53 +03:00
ExpiresInMinutes : expiresMinutes ,
2026-05-25 16:52:57 +03:00
MaxDownloads : parseInt ( r . FormValue ( "max_downloads" ) ) ,
Password : r . FormValue ( "password" ) ,
ObfuscateMetadata : r . FormValue ( "obfuscate_metadata" ) == "on" ,
2026-05-30 15:42:35 +03:00
OwnerID : ownerID ,
CollectionID : collectionID ,
2026-05-31 14:01:38 +03:00
SkipSizeLimit : isAdminUpload || effectivePolicy . MaxUploadMB < 0 ,
2026-05-31 02:14:10 +03:00
CreatorIP : uploadClientIP ( r ) ,
StorageBackendID : effectivePolicy . StorageBackendID ,
2026-05-31 22:27:43 +03:00
}
result , boxesAdded , err := a . createOrAppendBox ( r , user , loggedIn , files , opts )
2026-05-25 16:26:47 +03:00
if err != nil {
2026-05-31 21:52:56 +03:00
a . logger . Warn ( "upload failed" , "source" , "user-upload" , "severity" , "warn" , "code" , 4001 , "ip" , uploadClientIP ( r ) , "user_id" , user . ID , "error" , err . Error ( ) )
2026-05-25 16:26:47 +03:00
helpers . WriteJSONError ( w , http . StatusBadRequest , err . Error ( ) )
return
}
2026-05-30 17:23:20 +03:00
if ! isAdminUpload {
2026-05-31 22:27:43 +03:00
if err := a . recordUploadUsage ( r , user , loggedIn , totalBytes , boxesAdded ) ; err != nil {
2026-05-30 17:23:20 +03:00
a . logger . Warn ( "failed to record upload usage" , "source" , "quota" , "severity" , "warn" , "code" , 4402 , "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" , 4403 , "error" , err . Error ( ) )
}
}
2026-05-29 23:44:05 +03:00
jobs . GenerateThumbnailsForBoxAsync ( a . uploadService , a . logger , result . BoxID )
2026-05-31 21:52:56 +03:00
a . logger . Info ( "upload response sent" , "source" , "user-upload" , "severity" , "user_activity" , "code" , 2001 , "ip" , uploadClientIP ( r ) , "user_id" , user . ID , "box_id" , result . BoxID , "files" , len ( files ) , "bytes" , totalBytes , "admin" , isAdminUpload )
2026-05-25 16:26:47 +03:00
2026-05-29 23:44:05 +03:00
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 )
2026-05-25 16:26:47 +03:00
}
2026-05-31 22:27:43 +03:00
// createOrAppendBox creates a new box. It only ever appends to an existing box
// when the request opts in via the X-Warpbox-Batch header: requests sharing the
// same batch value (per account, or per IP for anonymous) within
// uploadGroupWindow are folded into one box. Without the header the behaviour is
// identical to creating a fresh box every time. Returns the result and how many
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
func ( a * App ) createOrAppendBox ( r * http . Request , user services . User , loggedIn bool , files [ ] * multipart . FileHeader , opts services . UploadOptions ) ( services . UploadResult , int , error ) {
batch := strings . TrimSpace ( r . Header . Get ( uploadBatchHeader ) )
if batch == "" {
result , err := a . uploadService . CreateBox ( files , opts )
if err != nil {
return services . UploadResult { } , 0 , err
}
return result , 1 , nil
}
// Group key is scoped to the uploader so batches never cross accounts/IPs.
identity := "ip:" + uploadClientIP ( r )
if loggedIn {
identity = "user:" + user . ID
}
entry := a . uploadGroups . entryFor ( identity + "|" + batch )
// Hold the per-key lock across the whole create/append so concurrent batched
// uploads serialise into the same box instead of racing.
entry . mu . Lock ( )
defer entry . mu . Unlock ( )
if entry . boxID != "" && time . Since ( entry . at ) < uploadGroupWindow {
if box , err := a . uploadService . GetBox ( entry . boxID ) ; err == nil && a . batchBoxMatches ( box , user , loggedIn , r ) && a . uploadService . CanDownload ( box ) == nil {
if result , err := a . uploadService . AppendFiles ( entry . boxID , files , opts ) ; err == nil {
// Re-attach the manage/delete URLs from the box's creation so every
// upload in the batch returns a working deletion URL.
result . ManageURL = entry . manageURL
result . DeleteURL = entry . deleteURL
entry . at = time . Now ( )
return result , 0 , nil
}
}
}
result , err := a . uploadService . CreateBox ( files , opts )
if err != nil {
return services . UploadResult { } , 0 , err
}
entry . boxID = result . BoxID
entry . manageURL = result . ManageURL
entry . deleteURL = result . DeleteURL
entry . at = time . Now ( )
return result , 1 , nil
}
// batchBoxMatches guards that a batched append only ever touches a box owned by
// the same uploader (account for logged-in users, creator IP for anonymous).
func ( a * App ) batchBoxMatches ( box services . Box , user services . User , loggedIn bool , r * http . Request ) bool {
if loggedIn {
return box . OwnerID == user . ID
}
return box . OwnerID == "" && box . CreatorIP == uploadClientIP ( r )
}
2026-05-31 02:14:10 +03:00
func ( a * App ) checkUploadPolicy ( r * http . Request , user services . User , loggedIn bool , settings services . UploadPolicySettings , policy services . EffectiveUploadPolicy , files [ ] * multipart . FileHeader , totalBytes int64 ) ( int , string ) {
2026-05-30 17:23:20 +03:00
if len ( files ) == 0 {
return 0 , ""
}
now := time . Now ( ) . UTC ( )
2026-05-31 02:14:10 +03:00
if policy . MaxUploadMB > 0 {
maxBytes := services . MegabytesToBytes ( policy . MaxUploadMB )
2026-05-30 17:23:20 +03:00
for _ , file := range files {
2026-05-31 02:14:10 +03:00
if file . Size > maxBytes {
return http . StatusRequestEntityTooLarge , "file exceeds upload size limit"
2026-05-30 17:23:20 +03:00
}
}
2026-05-31 02:14:10 +03:00
}
if ! loggedIn {
2026-05-30 17:23:20 +03:00
usage , err := a . settingsService . UsageForIP ( uploadClientIP ( r ) , now )
if err != nil {
return http . StatusInternalServerError , "upload usage could not be checked"
}
2026-05-31 14:01:38 +03:00
if policy . DailyUploadMB > 0 && usage . UploadedBytes + totalBytes > services . MegabytesToBytes ( policy . DailyUploadMB ) {
2026-05-30 17:23:20 +03:00
return http . StatusTooManyRequests , "anonymous daily upload limit reached"
}
2026-05-31 02:14:10 +03:00
if usage . UploadedBoxes + 1 > policy . DailyBoxes {
return http . StatusTooManyRequests , "anonymous daily box limit reached"
}
activeBoxes , err := a . uploadService . ActiveBoxCountForIP ( uploadClientIP ( r ) )
if err != nil {
return http . StatusInternalServerError , "active box limit could not be checked"
}
if activeBoxes + 1 > policy . ActiveBoxes {
return http . StatusTooManyRequests , "anonymous active box limit reached"
}
if status , message := a . checkStorageBackendCapacity ( policy . StorageBackendID , settings , totalBytes ) ; message != "" {
return status , message
}
2026-05-30 17:23:20 +03:00
return 0 , ""
}
usage , err := a . settingsService . UsageForUser ( user . ID , now )
if err != nil {
return http . StatusInternalServerError , "upload usage could not be checked"
}
2026-05-31 14:01:38 +03:00
if policy . DailyUploadMB > 0 && usage . UploadedBytes + totalBytes > services . MegabytesToBytes ( policy . DailyUploadMB ) {
2026-05-30 17:23:20 +03:00
return http . StatusTooManyRequests , "daily upload limit reached"
}
2026-05-31 02:14:10 +03:00
if usage . UploadedBoxes + 1 > policy . DailyBoxes {
return http . StatusTooManyRequests , "daily box limit reached"
}
activeBoxes , err := a . uploadService . ActiveBoxCountForUser ( user . ID )
if err != nil {
return http . StatusInternalServerError , "active box limit could not be checked"
}
if activeBoxes + 1 > policy . ActiveBoxes {
return http . StatusTooManyRequests , "active box limit reached"
}
2026-05-30 17:23:20 +03:00
activeStorage , err := a . uploadService . UserActiveStorageUsed ( user . ID )
if err != nil {
return http . StatusInternalServerError , "storage quota could not be checked"
}
2026-05-31 02:14:10 +03:00
if policy . StorageQuotaSet && activeStorage + totalBytes > services . MegabytesToBytes ( policy . StorageQuotaMB ) {
2026-05-30 17:23:20 +03:00
return http . StatusRequestEntityTooLarge , "storage quota reached"
}
2026-05-31 02:14:10 +03:00
if status , message := a . checkStorageBackendCapacity ( policy . StorageBackendID , settings , totalBytes ) ; message != "" {
return status , message
}
2026-05-30 17:23:20 +03:00
return 0 , ""
}
2026-05-31 02:14:10 +03:00
func ( a * App ) recordUploadUsage ( r * http . Request , user services . User , loggedIn bool , totalBytes int64 , boxes int ) error {
2026-05-30 17:23:20 +03:00
now := time . Now ( ) . UTC ( )
if loggedIn {
2026-05-31 02:14:10 +03:00
return a . settingsService . AddUploadUsage ( "user" , user . ID , totalBytes , boxes , now )
2026-05-30 17:23:20 +03:00
}
2026-05-31 02:14:10 +03:00
return a . settingsService . AddUploadUsage ( "ip" , uploadClientIP ( r ) , totalBytes , boxes , now )
2026-05-30 17:23:20 +03:00
}
2026-05-31 02:14:10 +03:00
func ( a * App ) effectiveUploadPolicy ( settings services . UploadPolicySettings , user services . User , loggedIn bool ) services . EffectiveUploadPolicy {
2026-05-30 17:23:20 +03:00
if loggedIn {
2026-05-31 02:14:10 +03:00
return a . settingsService . EffectivePolicyForUser ( settings , user )
}
return a . settingsService . EffectivePolicyForAnonymous ( settings )
}
func ( a * App ) checkStorageBackendCapacity ( backendID string , settings services . UploadPolicySettings , totalBytes int64 ) ( int , string ) {
if backendID != services . StorageBackendLocal {
return 0 , ""
}
backend , err := a . uploadService . Storage ( ) . Backend ( services . StorageBackendLocal )
if err != nil {
return http . StatusInternalServerError , "storage backend could not be checked"
}
used , err := backend . Usage ( context . Background ( ) )
if err != nil {
return http . StatusInternalServerError , "storage backend usage could not be checked"
}
if used + totalBytes > services . GigabytesToBytes ( settings . LocalStorageMaxGB ) {
return http . StatusRequestEntityTooLarge , "local storage limit reached"
}
return 0 , ""
}
func uploadParseLimit ( policy services . EffectiveUploadPolicy , loggedIn bool , fallback int64 ) int64 {
2026-05-31 14:01:38 +03:00
if policy . MaxUploadMB < 0 {
return - 1
}
2026-05-31 02:14:10 +03:00
if loggedIn && policy . MaxUploadMB <= 0 {
2026-05-30 17:23:20 +03:00
return fallback * 8
}
2026-05-31 02:14:10 +03:00
if policy . MaxUploadMB > 0 {
return services . MegabytesToBytes ( policy . MaxUploadMB ) * 8
}
return fallback * 8
2026-05-30 17:23:20 +03:00
}
func uploadClientIP ( r * http . Request ) string {
2026-05-31 21:52:56 +03:00
if ip , ok := services . ClientIPFromContext ( r ) ; ok {
return ip
}
return services . ClientIP ( r . RemoteAddr , r . Header . Get ( "X-Forwarded-For" ) , r . Header . Get ( "X-Real-IP" ) , nil )
2026-05-30 17:23:20 +03:00
}
2026-05-31 02:14:10 +03:00
func uploadRateKey ( r * http . Request , user services . User , loggedIn bool ) string {
if loggedIn {
return "user:" + user . ID
}
return "ip:" + uploadClientIP ( r )
}
2026-05-30 17:23:20 +03:00
func totalUploadBytes ( files [ ] * multipart . FileHeader ) int64 {
var total int64
for _ , file := range files {
total += file . Size
}
return total
}
2026-05-25 16:26:47 +03:00
func parseInt ( value string ) int {
if value == "" {
return 0
}
parsed , err := strconv . Atoi ( value )
if err != nil {
return 0
}
return parsed
}
func statusForDownloadError ( err error ) int {
if errors . Is ( err , http . ErrMissingFile ) {
return http . StatusNotFound
}
return http . StatusForbidden
2026-05-25 15:36:49 +03:00
}
2026-05-29 23:44:05 +03:00
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" )
}