Files
seaweedfs/weed/s3api/s3api_bucket_handlers.go
T
Chris Lu ba5b14b457 master, filer, s3api: bound the collection deletes that strand a caller (#11026)
* master: bound each volume server DeleteCollection, and finish the fan-out

A collection delete fanned out to every volume server holding it with
context.Background(), so a server that accepted the connection and then
went quiet held the whole delete open with nothing to end it. Each RPC is
bounded now, on the same budget allocateVolumeTimeout gives the other
master-to-volume-server admin RPC. The volume server runs the delete to
completion regardless of the request context, so giving up costs the
confirmation and not the deletion.

The walk itself is the caller's, not a per-server one:

- It outlives the caller. A cancelled request must not abandon a
  destructive fan-out part-done, with volumes left behind and no request
  still running to come back for them.
- It no longer stops at the first server that refuses, which left the
  collection on every server after it in the list. The first failure is
  still what is reported, and the collection stays in the topology so a
  later delete comes back for the rest.
- It sends one RPC per server rather than one per replica.
  ListVolumeServers reports a node once for every replica it holds, while
  DeleteCollection removes the whole collection from the server it
  reaches, so a collection with thousands of volumes repeated the same
  whole-collection delete thousands of times over.

Both passes run too. Returning after a failed normal pass left the
collection's EC shards in place with nothing left to retry them.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* master: delete the EC shards behind /col/delete too

The HTTP handler carried its own copy of the volume-server walk and only
ever ran the normal pass, so a collection deleted through it kept its EC
shards. It shares the gRPC path now, which also gets it the bounded RPCs
and the one-per-server fan-out.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* filer: bound the collection delete a bucket delete leaves behind

Deleting a bucket entry deletes its collection afterwards, deliberately
detached from the request so a client that hangs up cannot strand the
bucket's volumes. Detached meant unbounded, though: with the master down
or mid-election the wait for a leader has nothing to end it, so the
handler parks, and the client retrying behind it parks another.

It keeps outliving the request and now carries a deadline of its own. The
budget bounds the wait, not the work: the master keeps deleting on its own
fan-out once asked, so giving up costs the confirmation.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* s3api: bound the collection RPCs a bucket creation and deletion issue

Neither carried a deadline, so a transient failure anywhere down the chain
held the S3 request open until the client gave up on it. Both budgets are
taken outside the filer failover walk, so one budget covers the whole walk
rather than granting each filer a fresh one.

The walk itself stops when that budget is spent, and stops without blaming
anyone: the caller's own expiry is not evidence against the filer that was
answering, and the next filer has no time left to answer in either.
Recorded as a filer failure, a slow master upstream would flag every filer
in the walk, and the three failures that open the circuit take unrelated
object reads down with them.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP

* s3api: a failed collection listing no longer fails a bucket creation

PutBucket lists collections to notice a leftover one it is about to reuse.
The result feeds a warning and nothing else -- s3a.exists is what decides
whether the bucket already exists -- yet a transient failure of that
listing returned 500 and refused the creation. It is advisory now, so a
failure is logged and the creation continues, exactly as it does when the
listing returns false.

Claude-Session: https://claude.ai/code/session_01EnB1fbryyKc2LetRZxQPTP
2026-08-28 16:32:30 -07:00

1463 lines
54 KiB
Go

package s3api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/private/protocol/xml/xmlutil"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3bucket"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/s3api/lifecycle_xml"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
// A bucket creation lists collections, and a bucket deletion deletes one.
// Neither RPC carried a deadline, so a transient failure anywhere down the chain
// -- gateway to filer, filer to master, master to volume server -- held the S3
// request open until the client gave up on it. Both budgets are taken outside
// the filer failover walk, so they cover the whole walk rather than granting
// each filer a fresh one.
//
// The delete is the shorter of the two: the filer has already spent its own
// budget on this collection, under the bucket entry's delete inside s3a.rm, and
// this call is the follow-up for when that did not happen.
const (
collectionListTimeout = 15 * time.Second
collectionDeleteTimeout = 10 * time.Second
)
func (s3a *S3ApiServer) ListBucketsHandler(w http.ResponseWriter, r *http.Request) {
glog.V(3).Infof("ListBucketsHandler")
// Get authenticated identity from context (set by Auth middleware)
// For unauthenticated requests, this returns empty string
identityId := s3_constants.GetIdentityNameFromContext(r)
// Get the full identity object for permission and ownership checks
// This is especially important for JWT users whose identity is not in the identities list
// Note: We store the full Identity object in context for simplicity. Future optimization
// could use a lightweight, credential-free view (name, account, actions, principal ARN)
// for better data minimization.
var identity *Identity
if s3a.iam.isEnabled() {
// Try to get the full identity from context first (works for all auth types including JWT)
if identityObj := s3_constants.GetIdentityFromContext(r); identityObj != nil {
if id, ok := identityObj.(*Identity); ok {
identity = id
} else {
glog.Warningf("ListBucketsHandler: identity object in context has unexpected type: %T", identityObj)
}
}
// Fallback to looking up by name if not in context (backward compatibility)
if identity == nil && identityId != "" {
identity = s3a.iam.lookupByIdentityName(identityId)
}
}
maxBuckets, prefix, startAfter, errCode := getListBucketsArgs(r.URL.Query())
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
var buckets []ListAllMyBucketsEntry
var nextToken string
// Unauthenticated users should not see any buckets
if identity != nil {
var err error
if fromIndex, granted := s3a.iam.canListBucketsFromOwnerIndex(r, identity); fromIndex && s3a.bucketOwnerIndexReady() {
buckets, nextToken, err = s3a.listBucketsFromOwnerIndex(r, identity, granted, prefix, startAfter, maxBuckets)
} else {
buckets, nextToken, err = s3a.scanVisibleBuckets(r, identity, prefix, startAfter, maxBuckets)
}
if err != nil {
glog.Errorf("ListBucketsHandler: %v", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
}
response := ListAllMyBucketsResult{
Owner: CanonicalUser{
ID: identityId,
DisplayName: identityId,
},
Buckets: ListAllMyBucketsList{Bucket: buckets},
ContinuationToken: nextToken,
Prefix: prefix,
}
glog.V(3).Infof("ListBucketsHandler response: %+v", response)
writeSuccessResponseXML(w, r, response)
}
const (
// maxBucketsPerPage caps a single ListBuckets response, matching AWS.
maxBucketsPerPage = 10000
// bucketScanPageSize is the filer page size while scanning /buckets.
bucketScanPageSize = 1000
)
func getListBucketsArgs(values url.Values) (maxBuckets int, prefix string, startAfter string, errCode s3err.ErrorCode) {
maxBuckets = maxBucketsPerPage
if v := values.Get("max-buckets"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > maxBucketsPerPage {
return 0, "", "", s3err.ErrInvalidMaxBuckets
}
maxBuckets = n
}
prefix = values.Get("prefix")
if v := values.Get("continuation-token"); v != "" {
name, err := decodeContinuationToken(v)
if err != nil {
return 0, "", "", s3err.ErrInvalidContinuationToken
}
startAfter = name
}
return maxBuckets, prefix, startAfter, s3err.ErrNone
}
// The continuation token is the last returned bucket name, base64-encoded so
// clients treat it as opaque per the S3 API contract.
func encodeContinuationToken(bucket string) string {
return base64.RawURLEncoding.EncodeToString([]byte(bucket))
}
func decodeContinuationToken(token string) (string, error) {
name, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(name) == 0 {
return "", fmt.Errorf("invalid continuation token %q", token)
}
return string(name), nil
}
// bucketVisibleToIdentity reports whether ListBuckets should include the bucket:
// the identity owns it or has explicit permission to list it.
func (s3a *S3ApiServer) bucketVisibleToIdentity(r *http.Request, bucket, owner string, identity *Identity) bool {
if isBucketOwnedByIdentity(owner, identity) {
return true
}
return s3a.iam.VerifyActionPermission(r, identity, s3_constants.ACTION_LIST, bucket, "") == s3err.ErrNone
}
// scanVisibleBuckets pages through /buckets and collects up to maxBuckets
// entries visible to the identity, returning a continuation token when more
// remain. It never buffers the full bucket set.
func (s3a *S3ApiServer) scanVisibleBuckets(r *http.Request, identity *Identity, prefix, startAfter string, maxBuckets int) (buckets []ListAllMyBucketsEntry, nextToken string, err error) {
startFrom := startAfter
for len(buckets) <= maxBuckets {
entries, isLast, listErr := s3a.list(s3a.option.BucketsPath, prefix, startFrom, false, bucketScanPageSize)
if listErr != nil {
return nil, "", listErr
}
if len(entries) == 0 {
break
}
for _, entry := range entries {
startFrom = entry.Name
if !entry.IsDirectory || strings.HasPrefix(entry.Name, ".") {
continue
}
if !s3a.bucketVisibleToIdentity(r, entry.Name, bucketEntryOwner(entry), identity) {
continue
}
buckets = append(buckets, ListAllMyBucketsEntry{
Name: entry.Name,
CreationDate: time.Unix(entry.Attributes.Crtime, 0).UTC(),
})
if len(buckets) > maxBuckets {
break
}
}
if isLast || len(buckets) > maxBuckets {
break
}
}
if len(buckets) > maxBuckets {
buckets = buckets[:maxBuckets]
nextToken = encodeContinuationToken(buckets[maxBuckets-1].Name)
}
return buckets, nextToken, nil
}
// isBucketOwnedByIdentity checks if a bucket with the given owner id (its
// AmzIdentityId metadata) is owned by the given identity.
//
// Ownership rules:
// - Admin users: considered owners of all buckets
// - Non-admin users: own buckets whose owner id matches identity.Name
// - Buckets without owner metadata are not owned by anyone (except admins)
func isBucketOwnedByIdentity(owner string, identity *Identity) bool {
if identity == nil {
return false
}
// Admin users are considered owners of all buckets
if identity.isAdmin() {
return true
}
// Non-admin users with no name cannot own buckets.
// This prevents misconfigured identities from matching buckets with empty owner IDs.
return identity.Name != "" && identity.Name == owner
}
func (s3a *S3ApiServer) PutBucketHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
// validate the bucket name
err := s3bucket.VerifyS3BucketName(bucket)
if err != nil {
glog.Errorf("put invalid bucket name: %v %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
return
}
// Check if bucket already exists and handle ownership/settings
// Get authenticated identity from context (secure, cannot be spoofed)
currentIdentityId := s3_constants.GetIdentityNameFromContext(r)
// Parse any requested bucket ACL (canned ACL or grant headers) up front so it
// can be validated, persisted on creation, and factored into the already-exists
// response. A "private" canned ACL is the default and counts as no explicit ACL.
requestHasACL := hasExplicitBucketACL(r)
var aclGrantsBytes []byte
if requestHasACL {
accountId := getAccountId(r)
_, grants, errCode := ParseAndValidateAclHeaders(r, s3a.iam, "", accountId, accountId, false)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if len(grants) > 0 {
grantsBytes, err := json.Marshal(grants)
if err != nil {
glog.Errorf("PutBucketHandler: marshal ACL grants for %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
aclGrantsBytes = grantsBytes
}
}
// Check collection existence first
collectionExists := false
if s3a.isTableBucket(bucket) {
s3err.WriteErrorResponse(w, r, s3err.ErrBucketAlreadyExists)
return
}
listCtx, cancelList := context.WithTimeout(r.Context(), collectionListTimeout)
if err := s3a.withFilerClient(listCtx, false, func(client filer_pb.SeaweedFilerClient) error {
if resp, err := client.CollectionList(listCtx, &filer_pb.CollectionListRequest{
IncludeEcVolumes: true,
IncludeNormalVolumes: true,
}); err != nil {
return fmt.Errorf("list collections: %w", err)
} else {
for _, c := range resp.Collections {
if s3a.getCollectionName(bucket) == c.Name {
collectionExists = true
break
}
}
}
return nil
}); err != nil {
// Advisory: the answer decides nothing below except whether to log that a
// leftover collection is being reused. s3a.exists is what decides whether
// the bucket already exists, so a listing that failed is no reason to
// refuse the creation.
glog.Warningf("PutBucketHandler: list collections for %s: %v", bucket, err)
}
cancelList()
// Bucket already exists: report whether the caller already owns it or the
// name is taken / the request conflicts.
if exist, err := s3a.exists(s3a.option.BucketsPath, bucket, true); err == nil && exist {
errCode := s3a.existingBucketError(r, bucket, currentIdentityId, requestHasACL)
if errCode == s3err.ErrBucketAlreadyOwnedByYou {
s3a.healBucketOwnerIndex(bucket, currentIdentityId)
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
// If collection exists but bucket directory doesn't, this is an inconsistent state
// that can occur when a previous bucket deletion partially completed (collection
// deletion failed but directory deletion succeeded, or volumes were recreated).
// Recover by proceeding to create the missing bucket directory.
if collectionExists {
glog.Warningf("PutBucketHandler: collection exists but bucket directory missing for %s, recovering by creating bucket directory", bucket)
}
// Check for x-amz-bucket-object-lock-enabled header BEFORE creating bucket
// This allows us to create the bucket with Object Lock configuration atomically
objectLockEnabled := strings.EqualFold(r.Header.Get(s3_constants.AmzBucketObjectLockEnabled), "true")
// Capture any Object Lock configuration error from within the callback
// The mkdir callback doesn't support returning errors, so we capture it here
var objectLockSetupError error
// Create the folder for bucket with all settings atomically
// This ensures Object Lock configuration is set in the same CreateEntry call,
// preventing race conditions where the bucket exists without Object Lock enabled
var bucketCrtime int64
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
bucketCrtime = entry.Attributes.Crtime
// Set bucket owner
setBucketOwner(r)(entry)
// Persist a requested non-default ACL so GetBucketAcl and idempotent
// recreation observe it (private is the default and is not stored).
if len(aclGrantsBytes) > 0 {
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
entry.Extended[s3_constants.ExtAmzAclKey] = aclGrantsBytes
}
// Set Object Lock configuration atomically during bucket creation
if objectLockEnabled {
glog.V(3).Infof("PutBucketHandler: enabling Object Lock and Versioning for bucket %s atomically", bucket)
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
// Enable versioning (required for Object Lock)
entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled)
// Create and store Object Lock configuration
objectLockConfig := &ObjectLockConfiguration{
ObjectLockEnabled: s3_constants.ObjectLockEnabled,
}
if err := StoreObjectLockConfigurationInExtended(entry, objectLockConfig); err != nil {
glog.Errorf("PutBucketHandler: failed to store Object Lock config for bucket %s: %v", bucket, err)
objectLockSetupError = err
// Note: The entry will still be created, but we'll roll it back below
} else {
glog.V(3).Infof("PutBucketHandler: set ObjectLockConfig for bucket %s: %+v", bucket, objectLockConfig)
}
}
}); err != nil {
// If mkdir failed because another request created the bucket concurrently,
// return the appropriate already-exists error instead of InternalError.
if exist, checkErr := s3a.exists(s3a.option.BucketsPath, bucket, true); checkErr == nil && exist {
glog.V(3).Infof("PutBucketHandler: bucket %s was created concurrently", bucket)
errCode := s3a.existingBucketError(r, bucket, currentIdentityId, requestHasACL)
if errCode == s3err.ErrBucketAlreadyOwnedByYou {
s3a.healBucketOwnerIndex(bucket, currentIdentityId)
}
s3err.WriteErrorResponse(w, r, errCode)
return
}
glog.Errorf("PutBucketHandler mkdir: %v", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// If Object Lock setup failed, roll back the bucket creation
// This ensures we don't leave a bucket without the requested Object Lock configuration
if objectLockSetupError != nil {
glog.Errorf("PutBucketHandler: rolling back bucket %s creation due to Object Lock setup failure: %v", bucket, objectLockSetupError)
if deleteErr := s3a.rm(context.Background(), s3a.option.BucketsPath, bucket, true, true); deleteErr != nil {
glog.Errorf("PutBucketHandler: failed to rollback bucket %s after Object Lock setup failure: %v", bucket, deleteErr)
}
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
// Remove bucket from negative cache after successful creation
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
// Index the bucket under its owner; the metadata subscription would catch
// up eventually, but writing here keeps ListBuckets read-your-writes.
if currentIdentityId != "" {
if err := s3a.addBucketToOwnerIndex(currentIdentityId, bucket, bucketCrtime); err != nil {
glog.Warningf("PutBucketHandler: owner index add %s/%s: %v", currentIdentityId, bucket, err)
}
}
w.Header().Set("Location", "/"+bucket)
writeSuccessResponseEmpty(w, r)
}
// healBucketOwnerIndex re-creates the owner index entry for a bucket the
// caller owns, repairing holes left by crashes between bucket creation and
// index write.
func (s3a *S3ApiServer) healBucketOwnerIndex(bucket, identityId string) {
if identityId == "" {
return
}
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone || config.IdentityId != identityId {
return
}
if err := s3a.addBucketToOwnerIndex(identityId, bucket, config.Crtime); err != nil {
glog.V(1).Infof("owner index heal %s/%s: %v", identityId, bucket, err)
}
}
func (s3a *S3ApiServer) DeleteBucketHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("DeleteBucketHandler %s", bucket)
// The teardown below retries, and a failover walk repeats it once per
// filer, so the backoff comes out of one allowance held here.
r = r.WithContext(withFilerRetryBudget(r.Context(), filerRetryRequestBudget))
if s3a.isTableBucket(bucket) {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Check if bucket has object lock enabled
bucketConfig, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// If object lock is enabled, check for objects with active locks
if bucketConfig.ObjectLockConfig != nil {
hasLockedObjects, checkErr := s3a.hasObjectsWithActiveLocks(r.Context(), bucket)
if checkErr != nil {
glog.Errorf("DeleteBucketHandler: failed to check for locked objects in bucket %s: %v", bucket, checkErr)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if hasLockedObjects {
glog.V(3).Infof("DeleteBucketHandler: bucket %s has objects with active object locks, cannot delete", bucket)
s3err.WriteErrorResponse(w, r, s3err.ErrBucketNotEmpty)
return
}
}
if !s3a.option.AllowDeleteBucketNotEmpty {
if hasUserObjects, err := s3a.bucketHasUserObjects(bucket); err != nil {
glog.Errorf("failed to list bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
} else if hasUserObjects {
s3err.WriteErrorResponse(w, r, s3err.ErrBucketNotEmpty)
return
}
}
// Delete bucket directory first, then collection. This order ensures that if
// collection deletion fails, the bucket directory is already gone, preventing
// the "collection exists but bucket directory missing" inconsistency that blocks
// bucket recreation. An orphaned collection is harmless and will be cleaned up
// or reused when the bucket is recreated.
err := s3a.rm(r.Context(), s3a.option.BucketsPath, bucket, false, true)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if owner := bucketConfig.IdentityId; owner != "" {
if err := s3a.removeBucketFromOwnerIndex(owner, bucket); err != nil {
glog.Warningf("DeleteBucketHandler: owner index remove %s/%s: %v", owner, bucket, err)
}
}
// Bounded on a background context: the bucket directory is already gone, so
// this follow-up must survive a client disconnect, but it must not outlive the
// client by an unbounded amount either.
deleteCtx, cancelDelete := context.WithTimeout(context.Background(), collectionDeleteTimeout)
err = s3a.withFilerClient(deleteCtx, false, func(client filer_pb.SeaweedFilerClient) error {
deleteCollectionRequest := &filer_pb.DeleteCollectionRequest{
Collection: s3a.getCollectionName(bucket),
}
glog.V(1).Infof("delete collection: %v", deleteCollectionRequest)
if _, err := client.DeleteCollection(deleteCtx, deleteCollectionRequest); err != nil {
return fmt.Errorf("delete collection %s: %v", bucket, err)
}
return nil
})
timedOut := deleteCtx.Err() != nil
cancelDelete()
if err != nil {
// Log but don't fail — the bucket directory is already removed, so the bucket
// is effectively deleted. The orphaned collection will be cleaned up or reused.
if timedOut {
// Our own budget, not a refusal: the master carries on deleting once asked.
glog.Warningf("DeleteBucketHandler: stopped waiting for the collection delete for bucket %s: %v", bucket, err)
} else {
glog.Errorf("DeleteBucketHandler: failed to delete collection for bucket %s: %v", bucket, err)
}
}
// Clean up bucket-related caches, locks, and metrics after successful deletion
s3a.invalidateBucketConfigCache(bucket)
stats_collect.DeleteBucketMetrics(bucket)
// Prune identity actions that were scoped to this bucket via s3.configure.
// Use a bounded background context so the cleanup survives client disconnect;
// the bucket is already gone and this is best-effort bookkeeping.
if s3a.iam != nil {
pruneCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if _, err := s3a.iam.PruneBucketFromConfiguration(pruneCtx, bucket); err != nil {
glog.Errorf("DeleteBucketHandler: failed to prune IAM actions for bucket %s: %v", bucket, err)
}
cancel()
}
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
}
// bucketHasUserObjects checks whether a bucket contains any non-special entries.
// Special entries (.uploads, *.versions) are internal to S3 and don't count as user objects.
func (s3a *S3ApiServer) bucketHasUserObjects(bucket string) (bool, error) {
bucketPath := s3a.option.BucketsPath + "/" + bucket
startFrom := ""
// Start with a small batch — most non-empty buckets have a real object early.
// If we only find special entries, switch to larger batches to page through quickly.
limit := uint32(10)
for {
entries, isLast, err := s3a.list(bucketPath, "", startFrom, false, limit)
if err != nil {
return false, err
}
for _, entry := range entries {
if entry.Name != s3_constants.MultipartUploadsFolder &&
!strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) {
return true, nil
}
startFrom = entry.Name
}
if isLast {
return false, nil
}
limit = 1000
}
}
// hasObjectsWithActiveLocks checks if any objects in the bucket have active retention or legal hold
// Delegates to the shared HasObjectsWithActiveLocks function in object_lock_utils.go
func (s3a *S3ApiServer) hasObjectsWithActiveLocks(ctx context.Context, bucket string) (bool, error) {
bucketPath := s3a.option.BucketsPath + "/" + bucket
var hasLocks bool
var checkErr error
err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
hasLocks, checkErr = HasObjectsWithActiveLocks(ctx, client, bucketPath)
return checkErr
})
if err != nil {
return false, err
}
return hasLocks, nil
}
func (s3a *S3ApiServer) HeadBucketHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("HeadBucketHandler %s", bucket)
if _, err := s3a.getBucketEntry(bucket); err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
glog.Errorf("HeadBucketHandler: failed to get bucket entry for %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
writeSuccessResponseEmpty(w, r)
}
func (s3a *S3ApiServer) checkBucket(r *http.Request, bucket string) s3err.ErrorCode {
// Use cached bucket config instead of direct getEntry call (optimization)
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
return errCode
}
//if iam is enabled, the access was already checked before
if s3a.iam.isEnabled() {
return s3err.ErrNone
}
if !s3a.hasAccess(r, config.IdentityId) {
return s3err.ErrAccessDenied
}
return s3err.ErrNone
}
// ErrAutoCreatePermissionDenied is returned when a user lacks permission to auto-create buckets
var ErrAutoCreatePermissionDenied = errors.New("permission denied - requires Admin permission")
// ErrAutoCreateDisabled is returned when bucket auto-creation is disabled by configuration
var ErrAutoCreateDisabled = errors.New("bucket auto-creation is disabled")
// ErrInvalidBucketName is returned when a bucket name doesn't meet S3 naming requirements
var ErrInvalidBucketName = errors.New("invalid bucket name")
// existingBucketError returns the error for a PutBucket whose target bucket
// already exists: BucketAlreadyOwnedByYou for an idempotent recreate by the
// owner, or BucketAlreadyExists when the name is owned by someone else or the
// request conflicts with the existing bucket (a different Object Lock setting,
// or an ACL on the request or the existing bucket).
func (s3a *S3ApiServer) existingBucketError(r *http.Request, bucket, currentIdentityId string, requestHasACL bool) s3err.ErrorCode {
entry, err := s3a.getEntry(s3a.option.BucketsPath, bucket)
if err != nil {
// We just observed the bucket exists but can't read it; report it as taken.
glog.Errorf("PutBucketHandler: failed to read existing bucket %s: %v", bucket, err)
return s3err.ErrBucketAlreadyExists
}
var existingOwnerId string
if entry.Extended != nil {
if id, ok := entry.Extended[s3_constants.AmzIdentityId]; ok {
existingOwnerId = string(id)
}
}
// Different owner: the name is taken in the shared namespace.
if existingOwnerId != "" && existingOwnerId != currentIdentityId {
glog.V(3).Infof("PutBucketHandler: bucket %s owned by %s, requested by %s", bucket, existingOwnerId, currentIdentityId)
return s3err.ErrBucketAlreadyExists
}
// Same owner (or an unowned bucket the caller can claim). Recreating your own
// bucket is idempotent and returns BucketAlreadyOwnedByYou, unless the request
// conflicts with the existing bucket: a different Object Lock setting, or an ACL
// on the request or the existing bucket.
// (s3-tests: test_bucket_create_exists vs test_bucket_recreate_*_acl.)
if requestHasACL {
return s3err.ErrBucketAlreadyExists
}
objectLockRequested := strings.EqualFold(r.Header.Get(s3_constants.AmzBucketObjectLockEnabled), "true")
bucketConfig, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
// Can't read the existing bucket's settings, so we can't tell whether this
// recreate conflicts; surface the failure instead of assuming idempotency.
glog.Errorf("PutBucketHandler: failed to get bucket config for %s: %v", bucket, errCode)
return errCode
}
currentObjectLockEnabled := bucketConfig.ObjectLockConfig != nil &&
bucketConfig.ObjectLockConfig.ObjectLockEnabled == s3_constants.ObjectLockEnabled
if objectLockRequested != currentObjectLockEnabled || len(bucketConfig.ACL) > 0 {
glog.V(3).Infof("PutBucketHandler: bucket %s already exists", bucket)
return s3err.ErrBucketAlreadyExists
}
glog.V(3).Infof("PutBucketHandler: bucket %s already owned by requester", bucket)
return s3err.ErrBucketAlreadyOwnedByYou
}
// hasExplicitBucketACL reports whether the request carries an explicit, non-default
// bucket ACL via a canned ACL header (other than "private") or grant headers.
func hasExplicitBucketACL(r *http.Request) bool {
if canned := r.Header.Get(s3_constants.AmzCannedAcl); canned != "" && !strings.EqualFold(canned, s3_constants.CannedAclPrivate) {
return true
}
for _, h := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
if r.Header.Get(h) != "" {
return true
}
}
return false
}
// setBucketOwner creates a function that sets the bucket owner from the request context
func setBucketOwner(r *http.Request) func(entry *filer_pb.Entry) {
currentIdentityId := s3_constants.GetIdentityNameFromContext(r)
// Record the canonical account id too so GetBucketAcl can report the bucket
// owner instead of whoever is reading (e.g. an admin or another account).
accountId := r.Header.Get(s3_constants.AmzAccountId)
return func(entry *filer_pb.Entry) {
if currentIdentityId == "" && accountId == "" {
return
}
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
if currentIdentityId != "" {
entry.Extended[s3_constants.AmzIdentityId] = []byte(currentIdentityId)
}
if accountId != "" {
entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(accountId)
}
}
}
// autoCreateBucket creates a bucket if it doesn't exist, setting the owner from the request context
// Only users with admin permissions are allowed to auto-create buckets
func (s3a *S3ApiServer) autoCreateBucket(r *http.Request, bucket string) error {
// Validate the bucket name before auto-creating
if err := s3bucket.VerifyS3BucketName(bucket); err != nil {
return fmt.Errorf("auto-create bucket %s: %w", bucket, errors.Join(ErrInvalidBucketName, err))
}
if !s3a.option.AutoCreateBucket {
return fmt.Errorf("auto-create bucket %s: %w", bucket, ErrAutoCreateDisabled)
}
// Check if user has admin permissions
if !s3a.isUserAdmin(r) {
return fmt.Errorf("auto-create bucket %s: %w", bucket, ErrAutoCreatePermissionDenied)
}
identityId := s3_constants.GetIdentityNameFromContext(r)
var bucketCrtime int64
if err := s3a.mkdir(s3a.option.BucketsPath, bucket, func(entry *filer_pb.Entry) {
bucketCrtime = entry.Attributes.Crtime
setBucketOwner(r)(entry)
}); err != nil {
// In case of a race condition where another request created the bucket
// in the meantime, check for existence before returning an error.
if exist, err2 := s3a.exists(s3a.option.BucketsPath, bucket, true); err2 != nil {
glog.Warningf("autoCreateBucket: failed to check existence for bucket %s: %v", bucket, err2)
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, errors.Join(err, err2))
} else if exist {
// The bucket exists, which is fine. However, we should ensure it has an owner.
// If it was created by a concurrent request that didn't set an owner,
// we'll set it here to ensure consistency.
if entry, getErr := s3a.getEntry(s3a.option.BucketsPath, bucket); getErr == nil {
if entry.Extended == nil || len(entry.Extended[s3_constants.AmzIdentityId]) == 0 {
// No owner set, assign current admin as owner
setBucketOwner(r)(entry)
if updateErr := s3a.updateEntry(s3a.option.BucketsPath, entry); updateErr != nil {
glog.Warningf("autoCreateBucket: failed to set owner for existing bucket %s: %v", bucket, updateErr)
} else {
glog.V(1).Infof("Set owner for existing bucket %s (created by concurrent request)", bucket)
if owner := bucketEntryOwner(entry); owner != "" {
if indexErr := s3a.addBucketToOwnerIndex(owner, bucket, entry.Attributes.Crtime); indexErr != nil {
glog.Warningf("autoCreateBucket: owner index add %s/%s: %v", owner, bucket, indexErr)
}
}
}
}
} else {
glog.Warningf("autoCreateBucket: failed to get entry for existing bucket %s: %v", bucket, getErr)
}
// Remove bucket from negative cache — it exists now
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
return nil
}
return fmt.Errorf("failed to auto-create bucket %s: %w", bucket, err)
}
// Remove bucket from negative cache after successful creation
if s3a.bucketConfigCache != nil {
s3a.bucketConfigCache.RemoveNegativeCache(bucket)
}
if identityId != "" {
if err := s3a.addBucketToOwnerIndex(identityId, bucket, bucketCrtime); err != nil {
glog.Warningf("autoCreateBucket: owner index add %s/%s: %v", identityId, bucket, err)
}
}
glog.V(1).Infof("Auto-created bucket %s", bucket)
return nil
}
// handleAutoCreateBucket attempts to auto-create a bucket and writes appropriate error responses
// Returns true if the bucket was created successfully or already exists, false if an error was written
func (s3a *S3ApiServer) handleAutoCreateBucket(w http.ResponseWriter, r *http.Request, bucket, handlerName string) bool {
if err := s3a.autoCreateBucket(r, bucket); err != nil {
glog.Warningf("%s: %v", handlerName, err)
// Check for specific errors to return appropriate S3 error codes
if errors.Is(err, ErrAutoCreateDisabled) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
} else if errors.Is(err, ErrInvalidBucketName) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketName)
} else if errors.Is(err, ErrAutoCreatePermissionDenied) {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
} else {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
return false
}
return true
}
// hasAccess checks the caller against the identity recorded at bucket
// creation; buckets with no recorded identity are open to any caller.
func (s3a *S3ApiServer) hasAccess(r *http.Request, bucketIdentityId string) bool {
// Check if user is properly authenticated as admin through IAM system
if s3a.isUserAdmin(r) {
return true
}
if bucketIdentityId == "" {
return true
}
// Get authenticated identity from context (secure, cannot be spoofed)
identityId := s3_constants.GetIdentityNameFromContext(r)
if identityId != bucketIdentityId {
glog.V(3).Infof("hasAccess: %s != %s", identityId, bucketIdentityId)
return false
}
return true
}
// isUserAdmin securely checks if the authenticated user is an admin
// This validates admin status through proper IAM authentication, not spoofable headers
func (s3a *S3ApiServer) isUserAdmin(r *http.Request) bool {
// Reuse the identity the Auth middleware stored; re-authenticating here would
// fail once the request body has been read.
if identityObj := s3_constants.GetIdentityFromContext(r); identityObj != nil {
if identity, ok := identityObj.(*Identity); ok {
return identity != nil && identity.isAdmin()
}
}
if s3a.iam == nil {
return false
}
// Use a minimal admin action to authenticate and check admin status
adminAction := Action("Admin")
identity, errCode := s3a.iam.authRequest(r, adminAction)
if errCode != s3err.ErrNone {
return false
}
// Check if the authenticated identity has admin privileges
return identity != nil && identity.isAdmin()
}
// isBucketPublicRead checks if a bucket allows anonymous read access based on its cached ACL status
func (s3a *S3ApiServer) isBucketPublicRead(bucket string) bool {
// Get bucket configuration which contains cached public-read status
config, errCode := s3a.getBucketConfig(bucket)
if errCode != s3err.ErrNone {
glog.V(4).Infof("isBucketPublicRead: failed to get bucket config for %s: %v", bucket, errCode)
return false
}
glog.V(4).Infof("isBucketPublicRead: bucket=%s, IsPublicRead=%v", bucket, config.IsPublicRead)
// Return the cached public-read status (no JSON parsing needed)
return config.IsPublicRead
}
// isPublicReadGrants checks if the grants allow public read access
func isPublicReadGrants(grants []*s3.Grant) bool {
for _, grant := range grants {
if grant.Grantee != nil && grant.Grantee.URI != nil && grant.Permission != nil {
// Check for AllUsers group with Read permission
if *grant.Grantee.URI == s3_constants.GranteeGroupAllUsers &&
(*grant.Permission == s3_constants.PermissionRead || *grant.Permission == s3_constants.PermissionFullControl) {
return true
}
}
}
return false
}
// buildResourceARN builds a resource ARN from bucket and object
// Used by the policy engine wrapper
func buildResourceARN(bucket, object string) string {
if object == "" || object == "/" {
return fmt.Sprintf("arn:aws:s3:::%s", bucket)
}
// Remove leading slash if present
object = strings.TrimPrefix(object, "/")
return fmt.Sprintf("arn:aws:s3:::%s/%s", bucket, object)
}
// AuthWithPublicRead creates an auth wrapper that allows anonymous access for public-read buckets
func (s3a *S3ApiServer) AuthWithPublicRead(handler http.HandlerFunc, action Action) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bucket, object := s3_constants.GetBucketAndObject(r)
authType := getRequestAuthType(r)
isAnonymous := authType == authTypeAnonymous
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, object=%s, authType=%v, isAnonymous=%v", bucket, object, authType, isAnonymous)
// For anonymous requests, check if bucket allows public read via ACLs or bucket policies
if isAnonymous {
// First check ACL-based public access
isPublic := s3a.isBucketPublicRead(bucket)
glog.V(4).Infof("AuthWithPublicRead: bucket=%s, isPublicACL=%v", bucket, isPublic)
if isPublic {
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to public-read bucket %s (ACL)", bucket)
handler(w, r)
return
}
// Check bucket policy for anonymous access using the policy engine
principal := "*" // Anonymous principal
// Evaluate bucket policy (objectEntry nil - not yet fetched)
allowed, evaluated, err := s3a.policyEngine.EvaluatePolicy(bucket, object, string(action), principal, r, nil, nil)
if err != nil {
// SECURITY: Fail-close on policy evaluation errors
// If we can't evaluate the policy, deny access rather than falling through to IAM
glog.Errorf("AuthWithPublicRead: error evaluating bucket policy for %s/%s: %v - denying access", bucket, object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
} else if evaluated {
// A bucket policy exists and was evaluated with a matching statement
if allowed {
// Policy explicitly allows anonymous access
glog.V(3).Infof("AuthWithPublicRead: allowing anonymous access to bucket %s (bucket policy)", bucket)
handler(w, r)
return
} else {
// Policy explicitly denies anonymous access
glog.V(3).Infof("AuthWithPublicRead: bucket policy explicitly denies anonymous access to %s/%s", bucket, object)
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
}
// No matching policy statement - fall through to check ACLs and then IAM auth
glog.V(3).Infof("AuthWithPublicRead: no bucket policy match for %s, checking ACLs", bucket)
}
// For all authenticated requests and anonymous requests to non-public buckets,
// use normal IAM auth to enforce policies
s3a.iam.Auth(handler, action)(w, r)
}
}
// GetBucketAclHandler Get Bucket ACL
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketAcl.html
func (s3a *S3ApiServer) GetBucketAclHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketAclHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Report the bucket's owner (recorded at creation or by PutBucketAcl), not the
// caller; fall back to the caller only when no owner was persisted. Likewise
// return any stored ACL, defaulting to the owner's full-control grant.
ownerId := r.Header.Get(s3_constants.AmzAccountId)
var storedGrants []*s3.Grant
if bucketConfig, errCode := s3a.getBucketConfig(bucket); errCode == s3err.ErrNone {
storedGrants = parseAclGrants(bucketConfig.ACL)
if bucketConfig.Owner != "" {
ownerId = bucketConfig.Owner
}
}
ownerDisplayName := s3a.iam.GetAccountNameById(ownerId)
response := AccessControlPolicy{
Owner: CanonicalUser{
ID: ownerId,
DisplayName: ownerDisplayName,
},
AccessControlList: buildAccessControlList(s3a.iam, storedGrants, ownerId, ownerDisplayName),
}
writeSuccessResponseXML(w, r, response)
}
// PutBucketAclHandler Put bucket ACL
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAcl.html //
func (s3a *S3ApiServer) PutBucketAclHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketAclHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Get account information for ACL processing
amzAccountId := r.Header.Get(s3_constants.AmzAccountId)
// Get bucket ownership settings (these would be used for ownership validation in a full implementation)
bucketOwnership := "" // Default/simplified for now - in a full implementation this would be retrieved from bucket config
bucketOwnerId := amzAccountId // Simplified - bucket owner is current account
// Use the existing ACL parsing logic to handle both canned ACLs and XML body
grants, errCode := ExtractAcl(r, s3a.iam, bucketOwnership, bucketOwnerId, amzAccountId, amzAccountId)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
glog.V(3).Infof("PutBucketAclHandler: bucket=%s, extracted %d grants", bucket, len(grants))
isPublic := isPublicReadGrants(grants)
glog.V(3).Infof("PutBucketAclHandler: bucket=%s, isPublicReadGrants=%v", bucket, isPublic)
// Store the bucket ACL in bucket metadata
errCode = s3a.updateBucketConfig(bucket, func(config *BucketConfig) error {
if len(grants) > 0 {
grantsBytes, err := json.Marshal(grants)
if err != nil {
glog.Errorf("PutBucketAclHandler: failed to marshal grants: %v", err)
return err
}
config.ACL = grantsBytes
// Cache the public-read status to avoid JSON parsing on every request
config.IsPublicRead = isPublicReadGrants(grants)
glog.V(4).Infof("PutBucketAclHandler: bucket=%s, setting IsPublicRead=%v", bucket, config.IsPublicRead)
} else {
config.ACL = nil
config.IsPublicRead = false
}
config.Owner = amzAccountId
return nil
})
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
glog.V(3).Infof("PutBucketAclHandler: Successfully stored ACL for bucket %s with %d grants", bucket, len(grants))
// Small delay to ensure ACL propagation across distributed caches
// This prevents race conditions in tests where anonymous access is attempted immediately after ACL change
time.Sleep(50 * time.Millisecond)
writeSuccessResponseEmpty(w, r)
}
// GetBucketLifecycleConfigurationHandler Get Bucket Lifecycle configuration
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLifecycleConfiguration.html
func (s3a *S3ApiServer) GetBucketLifecycleConfigurationHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketLifecycleConfigurationHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
if lifecycleXML, transitionMinimumObjectSize, found, errCode := s3a.getStoredBucketLifecycleConfiguration(bucket); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
} else if found {
w.Header().Set(bucketLifecycleTransitionMinimumObjectSizeHeader, transitionMinimumObjectSize)
writeSuccessResponseXMLBytes(w, r, lifecycleXML)
return
}
// ReadFilerConfFromFilers provides multi-filer failover
fc, err := filer.ReadFilerConfFromFilers(s3a.option.Filers, s3a.option.GrpcDialOption, nil)
if err != nil {
glog.Errorf("GetBucketLifecycleConfigurationHandler: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
ttls := fc.GetCollectionTtls(s3a.getCollectionName(bucket))
if len(ttls) == 0 {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchLifecycleConfiguration)
return
}
response := lifecycle_xml.Lifecycle{}
// Sort locationPrefixes to ensure consistent ordering of lifecycle rules
var locationPrefixes []string
for locationPrefix := range ttls {
locationPrefixes = append(locationPrefixes, locationPrefix)
}
sort.Strings(locationPrefixes)
for _, locationPrefix := range locationPrefixes {
internalTtl := ttls[locationPrefix]
ttl, _ := needle.ReadTTL(internalTtl)
days := int(ttl.Minutes() / 60 / 24)
if days == 0 {
continue
}
prefix, found := strings.CutPrefix(locationPrefix, fmt.Sprintf("%s/%s/", s3a.option.BucketsPath, bucket))
if !found {
continue
}
response.Rules = append(response.Rules, lifecycle_xml.Rule{
ID: prefix,
Status: lifecycle_xml.Enabled,
Prefix: lifecycle_xml.NewPrefix(prefix),
Expiration: lifecycle_xml.NewExpirationDays(days),
})
}
if len(response.Rules) > 0 {
w.Header().Set(bucketLifecycleTransitionMinimumObjectSizeHeader, defaultLifecycleTransitionMinimumObjectSize)
}
writeSuccessResponseXML(w, r, response)
}
// PutBucketLifecycleConfigurationHandler Put Bucket Lifecycle configuration
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketLifecycleConfiguration.html
func (s3a *S3ApiServer) PutBucketLifecycleConfigurationHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketLifecycleConfigurationHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxBucketLifecycleConfigurationSize)
lifecycleXML, err := io.ReadAll(r.Body)
if err != nil {
glog.Warningf("PutBucketLifecycleConfigurationHandler read body: %s", err)
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge)
return
}
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
lifeCycleConfig := lifecycle_xml.Lifecycle{}
if err := xmlDecoder(bytes.NewReader(lifecycleXML), &lifeCycleConfig, int64(len(lifecycleXML))); err != nil {
glog.Warningf("PutBucketLifecycleConfigurationHandler xml decode: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
// Reject Transition rules — they require storage class migration
// infrastructure that does not exist yet. Validate before touching
// any backing state so a malformed PUT can't half-apply.
for _, rule := range lifeCycleConfig.Rules {
if rule.Status != lifecycle_xml.Enabled {
continue
}
if rule.Transition.Set() || rule.NoncurrentVersionTransition.Set() {
s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented)
return
}
}
// Migration: clear any day-TTL filer.conf entries this handler
// installed in older builds. Per-write TTL is now driven by the
// LifecycleTTLResolver constructed off the stored XML, so leaving a
// stale day-TTL entry under /buckets/<bucket>/ would double-stamp
// (volume server expires under the old rule) or contradict the new
// XML after a rule change. The add path is gone — this loop only
// shrinks the conf, never grows it.
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer.ClearBucketLifecycleDayTTLs(context.Background(), client, s3a.option.BucketsPath, bucket, s3a.getCollectionName(bucket))
}); err != nil {
glog.Errorf("PutBucketLifecycleConfigurationHandler clear legacy day-TTLs: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if errCode := s3a.storeBucketLifecycleConfiguration(bucket, lifecycleXML, r.Header.Get(bucketLifecycleTransitionMinimumObjectSizeHeader)); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
writeSuccessResponseEmpty(w, r)
}
// DeleteBucketLifecycleHandler Delete Bucket Lifecycle
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketLifecycle.html
func (s3a *S3ApiServer) DeleteBucketLifecycleHandler(w http.ResponseWriter, r *http.Request) {
// collect parameters
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("DeleteBucketLifecycleHandler %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Same legacy day-TTL migration as the PUT handler.
if err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer.ClearBucketLifecycleDayTTLs(context.Background(), client, s3a.option.BucketsPath, bucket, s3a.getCollectionName(bucket))
}); err != nil {
glog.Errorf("DeleteBucketLifecycleHandler clear legacy day-TTLs: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if errCode := s3a.clearStoredBucketLifecycleConfiguration(bucket); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
}
// GetBucketLocationHandler Get bucket location
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html
func (s3a *S3ApiServer) GetBucketLocationHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
writeSuccessResponseXML(w, r, CreateBucketConfiguration{})
}
// GetBucketRequestPaymentHandler Get bucket location
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketRequestPayment.html
func (s3a *S3ApiServer) GetBucketRequestPaymentHandler(w http.ResponseWriter, r *http.Request) {
writeSuccessResponseXML(w, r, RequestPaymentConfiguration{Payer: "BucketOwner"})
}
// PutBucketOwnershipControls https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketOwnershipControls.html
func (s3a *S3ApiServer) PutBucketOwnershipControls(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketOwnershipControls %s", bucket)
errCode := s3a.checkAccessByOwnership(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if r.Body == nil || r.Body == http.NoBody {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
var v s3.OwnershipControls
defer util_http.CloseRequest(r)
err := xmlutil.UnmarshalXML(&v, xml.NewDecoder(r.Body), "")
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
if len(v.Rules) != 1 || v.Rules[0] == nil || v.Rules[0].ObjectOwnership == nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
printOwnership := true
ownership := *v.Rules[0].ObjectOwnership
switch ownership {
case s3_constants.OwnershipObjectWriter:
case s3_constants.OwnershipBucketOwnerPreferred:
case s3_constants.OwnershipBucketOwnerEnforced:
printOwnership = false
default:
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// Persist even when it matches the implicit default, so a later delete has something to remove.
if errCode := s3a.setBucketOwnership(bucket, ownership); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if printOwnership {
result := &s3.PutBucketOwnershipControlsInput{
OwnershipControls: &v,
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, result)
} else {
writeSuccessResponseEmpty(w, r)
}
}
// GetBucketOwnershipControls https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketOwnershipControls.html
func (s3a *S3ApiServer) GetBucketOwnershipControls(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketOwnershipControls %s", bucket)
errCode := s3a.checkAccessByOwnership(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// Get ownership using new bucket config system
ownership, errCode := s3a.getBucketOwnership(bucket)
if errCode == s3err.ErrNoSuchBucket {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
} else if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, s3err.OwnershipControlsNotFoundError)
return
}
result := &s3.PutBucketOwnershipControlsInput{
OwnershipControls: &s3.OwnershipControls{
Rules: []*s3.OwnershipControlsRule{
{
ObjectOwnership: &ownership,
},
},
},
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, result)
}
// DeleteBucketOwnershipControls https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketOwnershipControls.html
func (s3a *S3ApiServer) DeleteBucketOwnershipControls(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketOwnershipControls %s", bucket)
errCode := s3a.checkAccessByOwnership(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
bucketEntry, err := s3a.getEntry(s3a.option.BucketsPath, bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
return
}
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
_, ok := bucketEntry.Extended[s3_constants.ExtOwnershipKey]
if !ok {
s3err.WriteErrorResponse(w, r, s3err.OwnershipControlsNotFoundError)
return
}
delete(bucketEntry.Extended, s3_constants.ExtOwnershipKey)
err = s3a.updateEntry(s3a.option.BucketsPath, bucketEntry)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
emptyOwnershipControls := &s3.OwnershipControls{
Rules: []*s3.OwnershipControlsRule{},
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, emptyOwnershipControls)
}
// GetBucketVersioningHandler Get Bucket Versioning status
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketVersioning.html
func (s3a *S3ApiServer) GetBucketVersioningHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("GetBucketVersioning %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
// Get versioning status using new bucket config system
versioningStatus, errCode := s3a.getBucketVersioningStatus(bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
// AWS S3 behavior: If versioning was never configured, don't return Status field
var response *s3.PutBucketVersioningInput
if versioningStatus == "" {
// No versioning configuration - return empty response (no Status field)
response = &s3.PutBucketVersioningInput{
VersioningConfiguration: &s3.VersioningConfiguration{},
}
} else {
// Versioning was explicitly configured - return the status
response = &s3.PutBucketVersioningInput{
VersioningConfiguration: &s3.VersioningConfiguration{
Status: aws.String(versioningStatus),
},
}
}
s3err.WriteAwsXMLResponse(w, r, http.StatusOK, response)
}
// PutBucketVersioningHandler Put bucket Versioning
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketVersioning.html
func (s3a *S3ApiServer) PutBucketVersioningHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
glog.V(3).Infof("PutBucketVersioning %s", bucket)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
if r.Body == nil || r.Body == http.NoBody {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
var versioningConfig s3.VersioningConfiguration
defer util_http.CloseRequest(r)
err := xmlutil.UnmarshalXML(&versioningConfig, xml.NewDecoder(r.Body), "")
if err != nil {
glog.Warningf("PutBucketVersioningHandler xml decode: %s", err)
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
if versioningConfig.Status == nil {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
status := *versioningConfig.Status
if status != s3_constants.VersioningEnabled && status != s3_constants.VersioningSuspended {
glog.Errorf("PutBucketVersioningHandler: invalid status '%s' for bucket %s", status, bucket)
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// Check if trying to suspend versioning on a bucket with object lock enabled
if status == s3_constants.VersioningSuspended {
// Get bucket configuration to check for object lock
bucketConfig, errCode := s3a.getBucketConfig(bucket)
if errCode == s3err.ErrNone && bucketConfig.ObjectLockConfig != nil {
// Object lock is enabled, cannot suspend versioning
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidBucketState)
return
}
}
// Update bucket versioning configuration using new bucket config system
if errCode := s3a.setBucketVersioningStatus(bucket, status); errCode != s3err.ErrNone {
glog.Errorf("PutBucketVersioningHandler save config: bucket=%s, status='%s', errCode=%d", bucket, status, errCode)
s3err.WriteErrorResponse(w, r, errCode)
return
}
writeSuccessResponseEmpty(w, r)
}