mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-13 18:10:49 +02:00
* feat(s3api): apply lifecycle TTL at write time The S3 server already has the bucket's lifecycle XML at PUT time (via the cached BucketConfig), so volume-TTL routing is just a per-write decision instead of something that needs a separate filer.conf projection kept in sync via operator commands. - BucketConfig caches the canonical Rules parsed from the lifecycle XML once on load (BucketConfigCache invalidates on Put/Delete Lifecycle, so the rules stay current automatically). - resolveLifecycleTTLForWrite walks the cached rules: longest-prefix match, applies tag and size filters against the request, returns Days * 86400. Versioned buckets, non-Expiration.Days rules, and unevaluable size filters (no Content-Length) yield 0 — the lifecycle worker handles those at scan time. - putToFiler resolves TTL once and passes it through both the AssignVolumeRequest (so chunks land on a TTL volume) and the new entry's Attributes.TtlSec (so the filer's RocksDB compaction also expires the metadata). Lifecycle XML PUT/DELETE now influences write routing immediately — no operator command, no filer.conf bookkeeping. The lifecycle worker remains authoritative for the cases the fast path can't cover (existing objects via bootstrap, versioned buckets, noncurrent retention, abort-MPU, tag/size filters that didn't hold at PUT time). CompleteMultipartUpload and CopyObject still need wiring; left for follow-ups so this PR stays scoped. * perf(s3api): pre-filter and sort lifecycle rules for the per-PUT TTL walk resolveLifecycleTTLForWrite walked every lifecycle rule on every PutObject, including disabled / non-Expiration.Days rules that could never fire on the fast path, and computed "longest prefix wins" via a running max instead of an early exit. Cache a pre-filtered + pre-sorted slice in BucketConfig: - buildTTLFastPathRules drops everything except Status=Enabled + ExpirationDays>0; - sorts by descending prefix length (stable, so equal-length rules keep their XML order). The resolver returns on first prefix+filter match. A bucket whose lifecycle XML has no Expiration.Days rules is now O(1); a typical bucket with one Expiration.Days rule walks one HasPrefix per PUT. The cache is built once per bucket-config load. PutBucketLifecycle / DeleteBucketLifecycle already invalidate the cache, so the fast-path slice stays current automatically. * refactor(s3api): LifecycleTTLResolver object + four review fixes Pulls the per-PUT TTL resolution into a dedicated type so the bucket config holds one object instead of a slice + magic-walk function: - LifecycleTTLResolver wraps the pre-filtered, pre-sorted rules. nil-safe Resolve so the call site doesn't have to special-case buckets with no eligible rules. Four review findings: 1. (high) drop tag-filtered rules from the fast path. Tags are mutable post-PUT via PutObjectTagging but volume TTL is irreversible — an object that matched at write time would still expire after the tag was removed. Worker re-evaluates current tags at scan time. Fast path now keeps only stable predicates: prefix and size. 2. (high) move TTL resolution out of putToFiler. MPU parts, copy-part destinations, and other transient writes called putToFiler with object="" — bucket-wide rules (empty Prefix) matched and bound a TTL clock starting at part-upload time, before CompleteMultipartUpload existed. putToFiler now takes an explicit ttlSec parameter; only the user-visible PutObject paths (PutObjectHandler, postpolicy) feed it from the resolver. MPU and copy-part pass 0. 3. (medium) AWS overlapping-rule precedence is "shorter expiration wins", not "longest prefix wins". Sort by ExpirationDays ascending so the first prefix match is also the shortest applicable rule. 4. (medium) overflow no longer caps at math.MaxInt32 seconds (~68y). A longer policy would have expired early. Return 0 instead so the worker enforces the actual policy on its own schedule. Versioning gate moves into the resolver constructor — versioned buckets get a nil resolver. The five putToFiler callers all updated: PutObjectHandler + postpolicy resolve via lifecycleTTLForObjectWrite, suspended/versioned wrappers pass 0 by construction, MPU part and copy-part SSE pass 0 with a one-line comment about why. * refactor(s3api): drop unused BucketConfig.LifecycleRules field The full canonical rule set was set on every bucket-config load but never read — resolveLifecycleTTLForWrite worked off the resolver's filtered slice, and the lifecycle worker reads bucket entries straight off the meta-log instead of this cache. Remove the field and its s3lifecycle import. * perf(s3api): pre-compute LifecycleTTLResolver hot-path fields Resolve was doing per-call work that's actually constant per bucket- config load: int64 multiplication, max-int32 overflow check, field indirections through *s3lifecycle.Rule. Move it to the constructor and pack the rule into a compact ttlRule (prefix + ttlSec int32 + sizeGT/sizeLT) so the inner loop is HasPrefix → optional size check → return. Drop overflowing rules at construction rather than handling per- resolve: capping would expire long policies early, and returning 0 in the inner loop would prevent any shorter overlapping rule from firing. Drop-at-construction composes correctly with the ascending sort. Benchmarks (Apple M4): NilReceiver 0.99 ns/op 0 B/op OneRuleMatching 2.75 ns/op 0 B/op FiveRulesNoMatch 13.5 ns/op 0 B/op * fix(s3api): refresh LifecycleTTL resolver on bucket-config update storeBucketLifecycleConfiguration writes to Entry.Extended via updateBucketConfig, which clones the cached BucketConfig and calls the user fn, then caches the result. The clone inherits the prior LifecycleTTL pointer and nothing rebuilt it from the new XML, so add/replace/delete of a lifecycle policy left the wrong resolver in cache until eviction. Same gap on the meta-log side: peer-driven updates flowed through updateBucketConfigCacheFromEntry without re-deriving the resolver. Centralize the Entry -> derived-field mapping in one helper that resets every Extended-backed field then repopulates from the entry, and call it from getBucketConfig (initial load), updateBucketConfig (after updateEntry succeeds, before caching), and updateBucketConfigCacheFromEntry (meta-log path). Reset is the load-bearing part: deleting the lifecycle XML must yield a nil resolver, since stamping a stale TTL onto subsequent writes is irreversible. * fix(s3api): PostPolicy passes object size, not multipart wire size lifecycleTTLForObjectWrite was reading r.ContentLength, which on the PostPolicy path is the multipart envelope (form fields + boundaries), not the uploaded object body. A size-filtered rule would evaluate against that inflated total and stamp (or skip) a TTL the policy didn't intend. Take the object size as an explicit parameter. PutObject still passes r.ContentLength (correct there); PostPolicy passes the fileSize already extracted from the form part. Negative size means unknown and continues to skip any size-filtered rule. * fix(s3api): treat Object Lock as versioned for lifecycle TTL fast path Object Lock requires versioning at the API level, but it can be enabled at create time without S3 ever writing the explicit Versioning header. The lifecycle resolver construction site only checked Versioning, so an Object-Lock bucket with no Versioning byte would still get a fast-path resolver and stamp volume TTL onto writes — destroying noncurrent versions when the volume expires. Mirror the OR already used in BucketIsVersioned: ObjectLockConfig non-nil counts as versioned for resolver construction. Existing explicit-Versioning paths are unchanged.
325 lines
9.9 KiB
Go
325 lines
9.9 KiB
Go
package s3api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
)
|
|
|
|
func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html
|
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html
|
|
|
|
bucket := mux.Vars(r)["bucket"]
|
|
|
|
glog.V(3).Infof("PostPolicyBucketHandler %s", bucket)
|
|
|
|
reader, err := r.MultipartReader()
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
|
|
return
|
|
}
|
|
form, err := reader.ReadForm(int64(5 * humanize.MiByte))
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
|
|
return
|
|
}
|
|
defer form.RemoveAll()
|
|
|
|
fileBody, fileName, fileContentType, fileSize, formValues, err := extractPostPolicyFormValues(form)
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
|
|
return
|
|
}
|
|
if fileBody == nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrPOSTFileRequired)
|
|
return
|
|
}
|
|
defer fileBody.Close()
|
|
|
|
formValues.Set("Bucket", bucket)
|
|
|
|
if fileName != "" && strings.Contains(formValues.Get("Key"), "${filename}") {
|
|
formValues.Set("Key", strings.Replace(formValues.Get("Key"), "${filename}", fileName, -1))
|
|
}
|
|
object := s3_constants.NormalizeObjectKey(formValues.Get("Key"))
|
|
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
|
|
return
|
|
}
|
|
|
|
successRedirect := formValues.Get("success_action_redirect")
|
|
successStatus := formValues.Get("success_action_status")
|
|
var redirectURL *url.URL
|
|
if successRedirect != "" {
|
|
redirectURL, err = url.Parse(successRedirect)
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Verify policy signature.
|
|
errCode := s3a.iam.doesPolicySignatureMatch(formValues)
|
|
if errCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
|
|
policyBytes, err := base64.StdEncoding.DecodeString(formValues.Get("Policy"))
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
|
|
return
|
|
}
|
|
|
|
// Handle policy if it is set.
|
|
if len(policyBytes) > 0 {
|
|
|
|
postPolicyForm, err := policy.ParsePostPolicyForm(string(policyBytes))
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrPostPolicyConditionInvalidFormat)
|
|
return
|
|
}
|
|
|
|
// Make sure formValues adhere to policy restrictions.
|
|
if err = policy.CheckPostPolicy(formValues, postPolicyForm); err != nil {
|
|
glog.V(3).Infof("PostPolicy check failed for bucket %s: %v", bucket, err)
|
|
s3err.WriteErrorResponseWithMessage(w, r, s3err.ErrAccessDenied, err.Error())
|
|
return
|
|
}
|
|
|
|
// Ensure that the object size is within expected range, also the file size
|
|
// should not exceed the maximum single Put size (5 GiB)
|
|
lengthRange := postPolicyForm.Conditions.ContentLengthRange
|
|
if lengthRange.Valid {
|
|
if fileSize < lengthRange.Min {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooSmall)
|
|
return
|
|
}
|
|
|
|
if fileSize > lengthRange.Max {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
filePath := fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object)
|
|
|
|
// Get ContentType from post formData
|
|
// Otherwise from formFile ContentType
|
|
contentType := formValues.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = fileContentType
|
|
}
|
|
r.Header.Set("Content-Type", contentType)
|
|
|
|
// Forward validated POST form fields to the underlying PUT as headers.
|
|
applyPostPolicyFormHeaders(r, formValues)
|
|
|
|
// Use fileSize, not r.ContentLength: the multipart body wrapping form
|
|
// fields and boundaries inflates ContentLength relative to the
|
|
// object body, which would mis-evaluate any size-filtered rule.
|
|
ttlSec := s3a.lifecycleTTLForObjectWrite(bucket, object, fileSize)
|
|
etag, errCode, sseMetadata := s3a.putToFiler(r, filePath, fileBody, bucket, object, 1, ttlSec, nil)
|
|
|
|
if errCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
|
|
if successRedirect != "" {
|
|
// Replace raw query params..
|
|
redirectURL.RawQuery = getRedirectPostRawQuery(bucket, object, etag)
|
|
w.Header().Set("Location", redirectURL.String())
|
|
s3err.WriteEmptyResponse(w, r, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
setEtag(w, etag)
|
|
// Include SSE response headers (important for bucket-default encryption)
|
|
s3a.setSSEResponseHeaders(w, r, sseMetadata)
|
|
|
|
// Decide what http response to send depending on success_action_status parameter
|
|
switch successStatus {
|
|
case "201":
|
|
resp := PostResponse{
|
|
Bucket: bucket,
|
|
Key: object,
|
|
ETag: `"` + etag + `"`,
|
|
Location: w.Header().Get("Location"),
|
|
}
|
|
s3err.WriteXMLResponse(w, r, http.StatusCreated, resp)
|
|
s3err.PostLog(r, http.StatusCreated, s3err.ErrNone)
|
|
case "200":
|
|
s3err.WriteEmptyResponse(w, r, http.StatusOK)
|
|
case "204":
|
|
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
|
|
default:
|
|
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
|
|
}
|
|
|
|
}
|
|
|
|
// postPolicyReservedFormFields are multipart form fields that are part of the
|
|
// POST Object auth/policy mechanism (or handled explicitly elsewhere in the
|
|
// handler) and must not be forwarded to the upload as HTTP headers. Keys are
|
|
// already run through http.CanonicalHeaderKey before lookup.
|
|
var postPolicyReservedFormFields = map[string]struct{}{
|
|
// POST policy signature (V2)
|
|
"Policy": {},
|
|
"Signature": {},
|
|
"Awsaccesskeyid": {},
|
|
// POST policy signature (V4)
|
|
"X-Amz-Signature": {},
|
|
"X-Amz-Credential": {},
|
|
"X-Amz-Algorithm": {},
|
|
"X-Amz-Date": {},
|
|
"X-Amz-Security-Token": {},
|
|
// Target descriptors
|
|
"Key": {},
|
|
"File": {},
|
|
"Bucket": {},
|
|
// Success actions (handled elsewhere in the handler)
|
|
"Success_action_redirect": {},
|
|
"Success_action_status": {},
|
|
"Redirect": {},
|
|
// Content-Type is resolved separately above (from form or file part)
|
|
"Content-Type": {},
|
|
}
|
|
|
|
// applyPostPolicyFormHeaders forwards validated POST Object form fields to the
|
|
// request headers so they are applied to the resulting PUT. Reserved fields
|
|
// that are part of the POST policy mechanism itself (signature, key, etc.) are
|
|
// skipped. The acl form field is translated to the X-Amz-Acl header to match
|
|
// how AWS promotes the form value to the underlying PUT.
|
|
func applyPostPolicyFormHeaders(r *http.Request, formValues http.Header) {
|
|
for k := range formValues {
|
|
if _, reserved := postPolicyReservedFormFields[k]; reserved {
|
|
continue
|
|
}
|
|
switch {
|
|
case k == "Acl":
|
|
r.Header.Set(s3_constants.AmzCannedAcl, formValues.Get(k))
|
|
case k == "Cache-Control",
|
|
k == "Expires",
|
|
k == "Content-Disposition",
|
|
k == "Content-Encoding",
|
|
k == "Content-Language":
|
|
r.Header.Set(k, formValues.Get(k))
|
|
case strings.HasPrefix(k, "X-Amz-"):
|
|
r.Header.Set(k, formValues.Get(k))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract form fields and file data from a HTTP POST Policy
|
|
func extractPostPolicyFormValues(form *multipart.Form) (filePart io.ReadCloser, fileName, fileContentType string, fileSize int64, formValues http.Header, err error) {
|
|
// / HTML Form values
|
|
fileName = ""
|
|
fileContentType = ""
|
|
|
|
// Canonicalize the form values into http.Header.
|
|
formValues = make(http.Header)
|
|
for k, v := range form.Value {
|
|
formValues[http.CanonicalHeaderKey(k)] = v
|
|
}
|
|
|
|
// Validate form values.
|
|
if err = validateFormFieldSize(formValues); err != nil {
|
|
return nil, "", "", 0, nil, err
|
|
}
|
|
|
|
// this means that filename="" was not specified for file key and Go has
|
|
// an ugly way of handling this situation. Refer here
|
|
// https://golang.org/src/mime/multipart/formdata.go#L61
|
|
if len(form.File) == 0 {
|
|
var b = &bytes.Buffer{}
|
|
for _, v := range formValues["File"] {
|
|
b.WriteString(v)
|
|
}
|
|
fileSize = int64(b.Len())
|
|
filePart = io.NopCloser(b)
|
|
return filePart, fileName, fileContentType, fileSize, formValues, nil
|
|
}
|
|
|
|
// Iterator until we find a valid File field and break
|
|
for k, v := range form.File {
|
|
canonicalFormName := http.CanonicalHeaderKey(k)
|
|
if canonicalFormName == "File" {
|
|
if len(v) == 0 {
|
|
return nil, "", "", 0, nil, errors.New("Invalid arguments specified")
|
|
}
|
|
// Fetch fileHeader which has the uploaded file information
|
|
fileHeader := v[0]
|
|
// Set filename
|
|
fileName = fileHeader.Filename
|
|
// Set contentType
|
|
fileContentType = fileHeader.Header.Get("Content-Type")
|
|
// Open the uploaded part
|
|
filePart, err = fileHeader.Open()
|
|
if err != nil {
|
|
return nil, "", "", 0, nil, err
|
|
}
|
|
// Compute file size
|
|
fileSize, err = filePart.(io.Seeker).Seek(0, 2)
|
|
if err != nil {
|
|
return nil, "", "", 0, nil, err
|
|
}
|
|
// Reset Seek to the beginning
|
|
_, err = filePart.(io.Seeker).Seek(0, 0)
|
|
if err != nil {
|
|
return nil, "", "", 0, nil, err
|
|
}
|
|
// File found and ready for reading
|
|
break
|
|
}
|
|
}
|
|
return filePart, fileName, fileContentType, fileSize, formValues, nil
|
|
}
|
|
|
|
// Validate form field size for s3 specification requirement.
|
|
func validateFormFieldSize(formValues http.Header) error {
|
|
// Iterate over form values
|
|
for k := range formValues {
|
|
// Check if value's field exceeds S3 limit
|
|
if int64(len(formValues.Get(k))) > int64(1*humanize.MiByte) {
|
|
return errors.New("Data size larger than expected")
|
|
}
|
|
}
|
|
|
|
// Success.
|
|
return nil
|
|
}
|
|
|
|
func getRedirectPostRawQuery(bucket, key, etag string) string {
|
|
redirectValues := make(url.Values)
|
|
redirectValues.Set("bucket", bucket)
|
|
redirectValues.Set("key", key)
|
|
redirectValues.Set("etag", "\""+etag+"\"")
|
|
return redirectValues.Encode()
|
|
}
|
|
|
|
// Check to see if Policy is signed correctly.
|
|
func (iam *IdentityAccessManagement) doesPolicySignatureMatch(formValues http.Header) s3err.ErrorCode {
|
|
// For SignV2 - Signature field will be valid
|
|
if _, ok := formValues["Signature"]; ok {
|
|
return iam.doesPolicySignatureV2Match(formValues)
|
|
}
|
|
return iam.doesPolicySignatureV4Match(formValues)
|
|
}
|