You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							428 lines
						
					
					
						
							15 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							428 lines
						
					
					
						
							15 KiB
						
					
					
				| package s3api | |
| 
 | |
| import ( | |
| 	"encoding/xml" | |
| 	"fmt" | |
| 	"io" | |
| 	"net/http" | |
| 	"slices" | |
| 	"strings" | |
| 
 | |
| 	"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" | |
| 
 | |
| 	"github.com/seaweedfs/seaweedfs/weed/filer" | |
| 
 | |
| 	"github.com/seaweedfs/seaweedfs/weed/s3api/s3err" | |
| 
 | |
| 	"github.com/seaweedfs/seaweedfs/weed/glog" | |
| 	"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" | |
| 	stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" | |
| 	"github.com/seaweedfs/seaweedfs/weed/util" | |
| ) | |
| 
 | |
| const ( | |
| 	deleteMultipleObjectsLimit = 1000 | |
| ) | |
| 
 | |
| func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Request) { | |
| 
 | |
| 	bucket, object := s3_constants.GetBucketAndObject(r) | |
| 	glog.V(3).Infof("DeleteObjectHandler %s %s", bucket, object) | |
| 
 | |
| 	// Check for specific version ID in query parameters | |
| 	versionId := r.URL.Query().Get("versionId") | |
| 
 | |
| 	// Get detailed versioning state for proper handling of suspended vs enabled versioning | |
| 	versioningState, err := s3a.getVersioningState(bucket) | |
| 	if err != nil { | |
| 		if err == filer_pb.ErrNotFound { | |
| 			s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) | |
| 			return | |
| 		} | |
| 		glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err) | |
| 		s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 		return | |
| 	} | |
| 
 | |
| 	versioningEnabled := (versioningState == s3_constants.VersioningEnabled) | |
| 	versioningSuspended := (versioningState == s3_constants.VersioningSuspended) | |
| 	versioningConfigured := (versioningState != "") | |
| 
 | |
| 	var auditLog *s3err.AccessLog | |
| 	if s3err.Logger != nil { | |
| 		auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone) | |
| 	} | |
| 
 | |
| 	if versioningConfigured { | |
| 		// Handle versioned delete based on specific versioning state | |
| 		if versionId != "" { | |
| 			// Delete specific version (same for both enabled and suspended) | |
| 			// Check object lock permissions before deleting specific version | |
| 			governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object) | |
| 			if err := s3a.enforceObjectLockProtections(r, bucket, object, versionId, governanceBypassAllowed); err != nil { | |
| 				glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err) | |
| 				s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) | |
| 				return | |
| 			} | |
| 
 | |
| 			// Delete specific version | |
| 			err := s3a.deleteSpecificObjectVersion(bucket, object, versionId) | |
| 			if err != nil { | |
| 				glog.Errorf("Failed to delete specific version %s: %v", versionId, err) | |
| 				s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 				return | |
| 			} | |
| 
 | |
| 			// Set version ID in response header | |
| 			w.Header().Set("x-amz-version-id", versionId) | |
| 		} else { | |
| 			// Delete without version ID - behavior depends on versioning state | |
| 			if versioningEnabled { | |
| 				// Enabled versioning: Create delete marker (logical delete) | |
| 				// AWS S3 behavior: Delete marker creation is NOT blocked by object retention | |
| 				// because it's a logical delete that doesn't actually remove the retained version | |
| 				deleteMarkerVersionId, err := s3a.createDeleteMarker(bucket, object) | |
| 				if err != nil { | |
| 					glog.Errorf("Failed to create delete marker: %v", err) | |
| 					s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 					return | |
| 				} | |
| 
 | |
| 				// Set delete marker version ID in response header | |
| 				w.Header().Set("x-amz-version-id", deleteMarkerVersionId) | |
| 				w.Header().Set("x-amz-delete-marker", "true") | |
| 			} else if versioningSuspended { | |
| 				// Suspended versioning: Actually delete the "null" version object | |
| 				glog.V(2).Infof("DeleteObjectHandler: deleting null version for suspended versioning %s/%s", bucket, object) | |
| 
 | |
| 				// Check object lock permissions before deleting "null" version | |
| 				governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object) | |
| 				if err := s3a.enforceObjectLockProtections(r, bucket, object, "null", governanceBypassAllowed); err != nil { | |
| 					glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err) | |
| 					s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) | |
| 					return | |
| 				} | |
| 
 | |
| 				// Delete the "null" version (the regular file) | |
| 				err := s3a.deleteSpecificObjectVersion(bucket, object, "null") | |
| 				if err != nil { | |
| 					glog.Errorf("Failed to delete null version: %v", err) | |
| 					s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 					return | |
| 				} | |
| 
 | |
| 				// Note: According to AWS S3 spec, suspended versioning should NOT return version ID headers | |
| 				// The object is deleted but no version information is returned | |
| 			} | |
| 		} | |
| 	} else { | |
| 		// Handle regular delete (non-versioned) | |
| 		// Check object lock permissions before deleting object | |
| 		governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object) | |
| 		if err := s3a.enforceObjectLockProtections(r, bucket, object, "", governanceBypassAllowed); err != nil { | |
| 			glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err) | |
| 			s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) | |
| 			return | |
| 		} | |
| 
 | |
| 		target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) | |
| 		dir, name := target.DirAndName() | |
| 
 | |
| 		err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { | |
| 
 | |
| 			if err := doDeleteEntry(client, dir, name, true, false); err != nil { | |
| 				return err | |
| 			} | |
| 
 | |
| 			if s3a.option.AllowEmptyFolder { | |
| 				return nil | |
| 			} | |
| 
 | |
| 			directoriesWithDeletion := make(map[string]int) | |
| 			if strings.LastIndex(object, "/") > 0 { | |
| 				directoriesWithDeletion[dir]++ | |
| 				// purge empty folders, only checking folders with deletions | |
| 				for len(directoriesWithDeletion) > 0 { | |
| 					directoriesWithDeletion = s3a.doDeleteEmptyDirectories(client, directoriesWithDeletion) | |
| 				} | |
| 			} | |
| 
 | |
| 			return nil | |
| 		}) | |
| 		if err != nil { | |
| 			s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 			return | |
| 		} | |
| 	} | |
| 
 | |
| 	if auditLog != nil { | |
| 		auditLog.Key = strings.TrimPrefix(object, "/") | |
| 		s3err.PostAccessLog(*auditLog) | |
| 	} | |
| 
 | |
| 	stats_collect.RecordBucketActiveTime(bucket) | |
| 	stats_collect.S3DeletedObjectsCounter.WithLabelValues(bucket).Inc() | |
| 	w.WriteHeader(http.StatusNoContent) | |
| } | |
| 
 | |
| // ObjectIdentifier represents an object to be deleted with its key name and optional version ID. | |
| type ObjectIdentifier struct { | |
| 	Key                   string `xml:"Key"` | |
| 	VersionId             string `xml:"VersionId,omitempty"` | |
| 	DeleteMarker          bool   `xml:"DeleteMarker,omitempty"` | |
| 	DeleteMarkerVersionId string `xml:"DeleteMarkerVersionId,omitempty"` | |
| } | |
| 
 | |
| // DeleteObjectsRequest - xml carrying the object key names which needs to be deleted. | |
| type DeleteObjectsRequest struct { | |
| 	// Element to enable quiet mode for the request | |
| 	Quiet bool | |
| 	// List of objects to be deleted | |
| 	Objects []ObjectIdentifier `xml:"Object"` | |
| } | |
| 
 | |
| // DeleteError structure. | |
| type DeleteError struct { | |
| 	Code      string `xml:"Code"` | |
| 	Message   string `xml:"Message"` | |
| 	Key       string `xml:"Key"` | |
| 	VersionId string `xml:"VersionId,omitempty"` | |
| } | |
| 
 | |
| // DeleteObjectsResponse container for multiple object deletes. | |
| type DeleteObjectsResponse struct { | |
| 	XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ DeleteResult" json:"-"` | |
| 
 | |
| 	// Collection of all deleted objects | |
| 	DeletedObjects []ObjectIdentifier `xml:"Deleted,omitempty"` | |
| 
 | |
| 	// Collection of errors deleting certain objects. | |
| 	Errors []DeleteError `xml:"Error,omitempty"` | |
| } | |
| 
 | |
| // DeleteMultipleObjectsHandler - Delete multiple objects | |
| func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *http.Request) { | |
| 
 | |
| 	bucket, _ := s3_constants.GetBucketAndObject(r) | |
| 	glog.V(3).Infof("DeleteMultipleObjectsHandler %s", bucket) | |
| 
 | |
| 	deleteXMLBytes, err := io.ReadAll(r.Body) | |
| 	if err != nil { | |
| 		s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 		return | |
| 	} | |
| 
 | |
| 	deleteObjects := &DeleteObjectsRequest{} | |
| 	if err := xml.Unmarshal(deleteXMLBytes, deleteObjects); err != nil { | |
| 		s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) | |
| 		return | |
| 	} | |
| 
 | |
| 	if len(deleteObjects.Objects) > deleteMultipleObjectsLimit { | |
| 		s3err.WriteErrorResponse(w, r, s3err.ErrInvalidMaxDeleteObjects) | |
| 		return | |
| 	} | |
| 
 | |
| 	var deletedObjects []ObjectIdentifier | |
| 	var deleteErrors []DeleteError | |
| 	var auditLog *s3err.AccessLog | |
| 
 | |
| 	directoriesWithDeletion := make(map[string]int) | |
| 
 | |
| 	if s3err.Logger != nil { | |
| 		auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone) | |
| 	} | |
| 
 | |
| 	// Get detailed versioning state for proper handling of suspended vs enabled versioning | |
| 	versioningState, err := s3a.getVersioningState(bucket) | |
| 	if err != nil { | |
| 		if err == filer_pb.ErrNotFound { | |
| 			s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) | |
| 			return | |
| 		} | |
| 		glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err) | |
| 		s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) | |
| 		return | |
| 	} | |
| 
 | |
| 	versioningEnabled := (versioningState == s3_constants.VersioningEnabled) | |
| 	versioningSuspended := (versioningState == s3_constants.VersioningSuspended) | |
| 	versioningConfigured := (versioningState != "") | |
| 
 | |
| 	s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { | |
| 
 | |
| 		// delete file entries | |
| 		for _, object := range deleteObjects.Objects { | |
| 			if object.Key == "" { | |
| 				continue | |
| 			} | |
| 
 | |
| 			// Check object lock permissions before deletion (only for versioned buckets) | |
| 			if versioningConfigured { | |
| 				// Validate governance bypass for this specific object | |
| 				governanceBypassAllowed := s3a.evaluateGovernanceBypassRequest(r, bucket, object.Key) | |
| 				if err := s3a.enforceObjectLockProtections(r, bucket, object.Key, object.VersionId, governanceBypassAllowed); err != nil { | |
| 					glog.V(2).Infof("DeleteMultipleObjectsHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object.Key, object.VersionId, err) | |
| 					deleteErrors = append(deleteErrors, DeleteError{ | |
| 						Code:      s3err.GetAPIError(s3err.ErrAccessDenied).Code, | |
| 						Message:   s3err.GetAPIError(s3err.ErrAccessDenied).Description, | |
| 						Key:       object.Key, | |
| 						VersionId: object.VersionId, | |
| 					}) | |
| 					continue | |
| 				} | |
| 			} | |
| 
 | |
| 			var deleteVersionId string | |
| 			var isDeleteMarker bool | |
| 
 | |
| 			if versioningConfigured { | |
| 				// Handle versioned delete based on specific versioning state | |
| 				if object.VersionId != "" { | |
| 					// Delete specific version (same for both enabled and suspended) | |
| 					err := s3a.deleteSpecificObjectVersion(bucket, object.Key, object.VersionId) | |
| 					if err != nil { | |
| 						deleteErrors = append(deleteErrors, DeleteError{ | |
| 							Code:      "", | |
| 							Message:   err.Error(), | |
| 							Key:       object.Key, | |
| 							VersionId: object.VersionId, | |
| 						}) | |
| 						continue | |
| 					} | |
| 					deleteVersionId = object.VersionId | |
| 				} else { | |
| 					// Delete without version ID - behavior depends on versioning state | |
| 					if versioningEnabled { | |
| 						// Enabled versioning: Create delete marker (logical delete) | |
| 						deleteMarkerVersionId, err := s3a.createDeleteMarker(bucket, object.Key) | |
| 						if err != nil { | |
| 							deleteErrors = append(deleteErrors, DeleteError{ | |
| 								Code:      "", | |
| 								Message:   err.Error(), | |
| 								Key:       object.Key, | |
| 								VersionId: object.VersionId, | |
| 							}) | |
| 							continue | |
| 						} | |
| 						deleteVersionId = deleteMarkerVersionId | |
| 						isDeleteMarker = true | |
| 					} else if versioningSuspended { | |
| 						// Suspended versioning: Actually delete the "null" version object | |
| 						glog.V(2).Infof("DeleteMultipleObjectsHandler: deleting null version for suspended versioning %s/%s", bucket, object.Key) | |
| 
 | |
| 						err := s3a.deleteSpecificObjectVersion(bucket, object.Key, "null") | |
| 						if err != nil { | |
| 							deleteErrors = append(deleteErrors, DeleteError{ | |
| 								Code:      "", | |
| 								Message:   err.Error(), | |
| 								Key:       object.Key, | |
| 								VersionId: "null", | |
| 							}) | |
| 							continue | |
| 						} | |
| 						deleteVersionId = "null" | |
| 						// Note: For suspended versioning, we don't set isDeleteMarker=true | |
| 						// because we actually deleted the object, not created a delete marker | |
| 					} | |
| 				} | |
| 
 | |
| 				// Add to successful deletions with version info | |
| 				deletedObject := ObjectIdentifier{ | |
| 					Key:          object.Key, | |
| 					VersionId:    deleteVersionId, | |
| 					DeleteMarker: isDeleteMarker, | |
| 				} | |
| 
 | |
| 				// For delete markers, also set DeleteMarkerVersionId field | |
| 				if isDeleteMarker { | |
| 					deletedObject.DeleteMarkerVersionId = deleteVersionId | |
| 					// Don't set VersionId for delete markers, use DeleteMarkerVersionId instead | |
| 					deletedObject.VersionId = "" | |
| 				} | |
| 				if !deleteObjects.Quiet { | |
| 					deletedObjects = append(deletedObjects, deletedObject) | |
| 				} | |
| 				if isDeleteMarker { | |
| 					// For delete markers, we don't need to track directories for cleanup | |
| 					continue | |
| 				} | |
| 			} else { | |
| 				// Handle non-versioned delete (original logic) | |
| 				lastSeparator := strings.LastIndex(object.Key, "/") | |
| 				parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.Key, true, false | |
| 				if lastSeparator > 0 && lastSeparator+1 < len(object.Key) { | |
| 					entryName = object.Key[lastSeparator+1:] | |
| 					parentDirectoryPath = "/" + object.Key[:lastSeparator] | |
| 				} | |
| 				parentDirectoryPath = fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, parentDirectoryPath) | |
| 
 | |
| 				err := doDeleteEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive) | |
| 				if err == nil { | |
| 					directoriesWithDeletion[parentDirectoryPath]++ | |
| 					deletedObjects = append(deletedObjects, object) | |
| 				} else if strings.Contains(err.Error(), filer.MsgFailDelNonEmptyFolder) { | |
| 					deletedObjects = append(deletedObjects, object) | |
| 				} else { | |
| 					delete(directoriesWithDeletion, parentDirectoryPath) | |
| 					deleteErrors = append(deleteErrors, DeleteError{ | |
| 						Code:      "", | |
| 						Message:   err.Error(), | |
| 						Key:       object.Key, | |
| 						VersionId: object.VersionId, | |
| 					}) | |
| 				} | |
| 			} | |
| 
 | |
| 			if auditLog != nil { | |
| 				auditLog.Key = object.Key | |
| 				s3err.PostAccessLog(*auditLog) | |
| 			} | |
| 		} | |
| 
 | |
| 		if s3a.option.AllowEmptyFolder { | |
| 			return nil | |
| 		} | |
| 
 | |
| 		// purge empty folders, only checking folders with deletions | |
| 		for len(directoriesWithDeletion) > 0 { | |
| 			directoriesWithDeletion = s3a.doDeleteEmptyDirectories(client, directoriesWithDeletion) | |
| 		} | |
| 
 | |
| 		return nil | |
| 	}) | |
| 
 | |
| 	deleteResp := DeleteObjectsResponse{} | |
| 	if !deleteObjects.Quiet { | |
| 		deleteResp.DeletedObjects = deletedObjects | |
| 	} | |
| 	deleteResp.Errors = deleteErrors | |
| 	stats_collect.RecordBucketActiveTime(bucket) | |
| 	stats_collect.S3DeletedObjectsCounter.WithLabelValues(bucket).Add(float64(len(deletedObjects))) | |
| 
 | |
| 	writeSuccessResponseXML(w, r, deleteResp) | |
| 
 | |
| } | |
| 
 | |
| func (s3a *S3ApiServer) doDeleteEmptyDirectories(client filer_pb.SeaweedFilerClient, directoriesWithDeletion map[string]int) (newDirectoriesWithDeletion map[string]int) { | |
| 	var allDirs []string | |
| 	for dir := range directoriesWithDeletion { | |
| 		allDirs = append(allDirs, dir) | |
| 	} | |
| 	slices.SortFunc(allDirs, func(a, b string) int { | |
| 		return len(b) - len(a) | |
| 	}) | |
| 	newDirectoriesWithDeletion = make(map[string]int) | |
| 	for _, dir := range allDirs { | |
| 		parentDir, dirName := util.FullPath(dir).DirAndName() | |
| 		if parentDir == s3a.option.BucketsPath { | |
| 			continue | |
| 		} | |
| 		if err := doDeleteEntry(client, parentDir, dirName, false, false); err != nil { | |
| 			glog.V(4).Infof("directory %s has %d deletion but still not empty: %v", dir, directoriesWithDeletion[dir], err) | |
| 		} else { | |
| 			newDirectoriesWithDeletion[parentDir]++ | |
| 		} | |
| 	} | |
| 	return | |
| }
 |