88 "mime"
99 "mime/multipart"
1010 "net/http"
11+ "path"
12+ "regexp"
1113 "strconv"
1214 "strings"
1315
@@ -24,6 +26,8 @@ import (
2426
2527type S3FilesEnvelope Envelope [models.S3ListObjectsResponse , None ]
2628
29+ var trailingNumberPattern = regexp .MustCompile (`^(.*)-(\d+)$` )
30+
2731// resolvedS3 holds a ready-to-use S3 client and the resolved bucket name.
2832type resolvedS3 struct {
2933 client s3int.S3ClientInterface
@@ -232,9 +236,20 @@ func (app *App) GetS3FileHandler(w http.ResponseWriter, r *http.Request, _ httpr
232236 }
233237}
234238
239+ const defaultS3PostMaxCollisionAttempts = 10
240+
241+ func (app * App ) effectivePostS3CollisionAttempts () int {
242+ if app != nil && app .s3PostMaxCollisionAttempts > 0 {
243+ return app .s3PostMaxCollisionAttempts
244+ }
245+ return defaultS3PostMaxCollisionAttempts
246+ }
247+
235248// PostS3FileHandler uploads a file to S3 storage using credentials from a Kubernetes secret.
236249// Query parameters: namespace, secretName, key (required); bucket (optional, uses AWS_S3_BUCKET from secret if not provided).
237250// Request body: multipart/form-data with a file part named "file". Streams the file to S3 without buffering.
251+ // Candidate keys are chosen via HeadObject; the file is streamed to S3 once with If-None-Match (no full-file buffer).
252+ // If HeadObject and PUT disagree (concurrent writer), the handler returns 409 Conflict without retrying.
238253//
239254// Note: namespace is provided via the AttachNamespace middleware
240255func (app * App ) PostS3FileHandler (w http.ResponseWriter , r * http.Request , _ httprouter.Params ) {
@@ -263,6 +278,11 @@ func (app *App) PostS3FileHandler(w http.ResponseWriter, r *http.Request, _ http
263278
264279 ctx := r .Context ()
265280 bucket := s3 .bucket
281+ resolvedKey , err := resolveNonCollidingS3Key (ctx , s3 .client , bucket , key , app .effectivePostS3CollisionAttempts ())
282+ if err != nil {
283+ app .serverErrorResponse (w , r , fmt .Errorf ("error resolving S3 key for upload: %w" , err ))
284+ return
285+ }
266286
267287 maxUploadSize := app .s3PostMaxTotalBodyBytes ()
268288 // Cap the entire request body so chunked/unknown-length clients cannot force the multipart
@@ -331,25 +351,102 @@ func (app *App) PostS3FileHandler(w http.ResponseWriter, r *http.Request, _ http
331351 limitedFile := http .MaxBytesReader (nil , filePart , maxFilePartBytes )
332352 // MaxBytesReader.Close forwards to Part.Close, which drains the rest of the file part.
333353 defer limitedFile .Close ()
334- if err := s3 .client .UploadObject (ctx , bucket , key , limitedFile , contentType ); err != nil {
354+ if err := s3 .client .UploadObject (ctx , bucket , resolvedKey , limitedFile , contentType ); err != nil {
335355 var maxBytesErr * http.MaxBytesError
336356 if errors .As (err , & maxBytesErr ) {
337357 app .payloadTooLargeResponse (w , r , "file exceeds maximum size of 1 GiB" )
338358 return
339359 }
360+ if errors .Is (err , s3int .ErrObjectAlreadyExists ) {
361+ app .conflictResponse (w , r , fmt .Sprintf ("object key %q already exists in S3 (upload conflict); retry with a different key" , resolvedKey ))
362+ return
363+ }
340364 var accessDenied interface { ErrorCode () string }
341365 if errors .As (err , & accessDenied ) && accessDenied .ErrorCode () == "AccessDenied" {
342- app .forbiddenResponse (w , r , fmt .Sprintf ("access denied uploading to S3 '%s/%s'" , bucket , key ))
366+ app .forbiddenResponse (w , r , fmt .Sprintf ("access denied uploading to S3 '%s/%s'" , bucket , resolvedKey ))
343367 return
344368 }
345369 app .serverErrorResponse (w , r , fmt .Errorf ("error uploading file to S3: %w" , err ))
346370 return
347371 }
348372
349- body := map [string ]bool {"uploaded" : true }
373+ body := map [string ]any {
374+ "uploaded" : true ,
375+ "key" : resolvedKey ,
376+ }
350377 _ = app .WriteJSON (w , http .StatusCreated , body , nil )
351378}
352379
380+ // resolveNonCollidingS3Key picks a candidate object key using HeadObject only (no upload body read).
381+ // When the requested key exists, it tries name-1, name-2, … up to maxSuffixAttempts times.
382+ func resolveNonCollidingS3Key (
383+ ctx context.Context ,
384+ client s3int.S3ClientInterface ,
385+ bucket string ,
386+ requestedKey string ,
387+ maxCollisionAttempts int ,
388+ ) (string , error ) {
389+ exists , err := client .ObjectExists (ctx , bucket , requestedKey )
390+ if err != nil {
391+ return "" , err
392+ }
393+ if ! exists {
394+ return requestedKey , nil
395+ }
396+
397+ dir , name := splitS3ObjectPath (requestedKey )
398+ stem , ext := splitNameAndExtension (name )
399+ stemBase , nextIndex := splitStemAndNextIndex (stem )
400+
401+ for range maxCollisionAttempts {
402+ candidateName := fmt .Sprintf ("%s-%d%s" , stemBase , nextIndex , ext )
403+ candidateKey := dir + candidateName
404+
405+ candidateExists , checkErr := client .ObjectExists (ctx , bucket , candidateKey )
406+ if checkErr != nil {
407+ return "" , checkErr
408+ }
409+ if ! candidateExists {
410+ return candidateKey , nil
411+ }
412+ nextIndex ++
413+ }
414+ return "" , fmt .Errorf ("failed to resolve non-colliding S3 key after %d attempts" , maxCollisionAttempts )
415+ }
416+
417+ func splitS3ObjectPath (key string ) (dir string , name string ) {
418+ lastSlashIndex := strings .LastIndex (key , "/" )
419+ if lastSlashIndex == - 1 {
420+ return "" , key
421+ }
422+ return key [:lastSlashIndex + 1 ], key [lastSlashIndex + 1 :]
423+ }
424+
425+ func splitNameAndExtension (fileName string ) (stem string , ext string ) {
426+ ext = path .Ext (fileName )
427+ if ext == "" {
428+ return fileName , ""
429+ }
430+ stem = strings .TrimSuffix (fileName , ext )
431+ if stem == "" {
432+ return fileName , ""
433+ }
434+ return stem , ext
435+ }
436+
437+ func splitStemAndNextIndex (stem string ) (base string , nextIndex int ) {
438+ match := trailingNumberPattern .FindStringSubmatch (stem )
439+ if len (match ) != 3 {
440+ return stem , 1
441+ }
442+
443+ parsedIndex , err := strconv .Atoi (match [2 ])
444+ if err != nil {
445+ return stem , 1
446+ }
447+ return match [1 ], parsedIndex + 1
448+ }
449+
353450// rejectDeclaredOversizedS3Post returns 413 when Content-Length is set and exceeds
354451// s3PostMaxTotalBodyBytes. Chunked or unknown length passes here; PostS3FileHandler still wraps
355452// r.Body with http.MaxBytesReader before MultipartReader so total bytes read are capped.
0 commit comments