forked from TrueCloudLab/frostfs-s3-gw
309 lines
9.9 KiB
Go
309 lines
9.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data"
|
|
apierr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/util"
|
|
"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
maxRules = 1000
|
|
maxRuleIDLen = 255
|
|
maxNewerNoncurrentVersions = 100
|
|
)
|
|
|
|
func (h *handler) GetBucketLifecycleHandler(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
reqInfo := middleware.GetReqInfo(ctx)
|
|
|
|
bktInfo, err := h.getBucketAndCheckOwner(r, reqInfo.BucketName)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "could not get bucket info", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
cfg, err := h.obj.GetBucketLifecycleConfiguration(ctx, bktInfo)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "could not get bucket lifecycle configuration", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
if err = middleware.EncodeToResponse(w, cfg); err != nil {
|
|
h.logAndSendError(ctx, w, "could not encode GetBucketLifecycle response", reqInfo, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *handler) PutBucketLifecycleHandler(w http.ResponseWriter, r *http.Request) {
|
|
var buf bytes.Buffer
|
|
|
|
tee := io.TeeReader(r.Body, &buf)
|
|
ctx := r.Context()
|
|
reqInfo := middleware.GetReqInfo(ctx)
|
|
|
|
// Content-Md5 is required and should be set
|
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketLifecycleConfiguration.html
|
|
if _, ok := r.Header[api.ContentMD5]; !ok {
|
|
h.logAndSendError(ctx, w, "missing Content-MD5", reqInfo, apierr.GetAPIError(apierr.ErrMissingContentMD5))
|
|
return
|
|
}
|
|
|
|
headerMD5, err := base64.StdEncoding.DecodeString(r.Header.Get(api.ContentMD5))
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "invalid Content-MD5", reqInfo, apierr.GetAPIError(apierr.ErrInvalidDigest))
|
|
return
|
|
}
|
|
|
|
cfg := new(data.LifecycleConfiguration)
|
|
if err = h.cfg.NewXMLDecoder(tee).Decode(cfg); err != nil {
|
|
h.logAndSendError(ctx, w, "could not decode body", reqInfo, fmt.Errorf("%w: %s", apierr.GetAPIError(apierr.ErrMalformedXML), err.Error()))
|
|
return
|
|
}
|
|
|
|
bodyMD5, err := getContentMD5(&buf)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "could not get content md5", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
if !bytes.Equal(headerMD5, bodyMD5) {
|
|
h.logAndSendError(ctx, w, "Content-MD5 does not match", reqInfo, apierr.GetAPIError(apierr.ErrInvalidDigest))
|
|
return
|
|
}
|
|
|
|
bktInfo, err := h.getBucketAndCheckOwner(r, reqInfo.BucketName)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "could not get bucket info", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
networkInfo, err := h.obj.GetNetworkInfo(ctx)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "could not get network info", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
if err = checkLifecycleConfiguration(ctx, cfg, &networkInfo); err != nil {
|
|
h.logAndSendError(ctx, w, "invalid lifecycle configuration", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
params := &layer.PutBucketLifecycleParams{
|
|
BktInfo: bktInfo,
|
|
LifecycleCfg: cfg,
|
|
}
|
|
|
|
params.CopiesNumbers, err = h.pickCopiesNumbers(parseMetadata(r), reqInfo.Namespace, bktInfo.LocationConstraint)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "invalid copies number", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
if err = h.obj.PutBucketLifecycleConfiguration(ctx, params); err != nil {
|
|
h.logAndSendError(ctx, w, "could not put bucket lifecycle configuration", reqInfo, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *handler) DeleteBucketLifecycleHandler(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
reqInfo := middleware.GetReqInfo(ctx)
|
|
|
|
bktInfo, err := h.getBucketAndCheckOwner(r, reqInfo.BucketName)
|
|
if err != nil {
|
|
h.logAndSendError(ctx, w, "could not get bucket info", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
if err = h.obj.DeleteBucketLifecycleConfiguration(ctx, bktInfo); err != nil {
|
|
h.logAndSendError(ctx, w, "could not delete bucket lifecycle configuration", reqInfo, err)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func checkLifecycleConfiguration(ctx context.Context, cfg *data.LifecycleConfiguration, ni *netmap.NetworkInfo) error {
|
|
now := layer.TimeNow(ctx)
|
|
|
|
if len(cfg.Rules) > maxRules {
|
|
return fmt.Errorf("%w: number of rules cannot be greater than %d", apierr.GetAPIError(apierr.ErrInvalidRequest), maxRules)
|
|
}
|
|
|
|
ids := make(map[string]struct{}, len(cfg.Rules))
|
|
for i, rule := range cfg.Rules {
|
|
if rule.ID == "" {
|
|
id, err := uuid.NewRandom()
|
|
if err != nil {
|
|
return fmt.Errorf("generate uuid: %w", err)
|
|
}
|
|
cfg.Rules[i].ID = id.String()
|
|
rule.ID = id.String()
|
|
}
|
|
|
|
if _, ok := ids[rule.ID]; ok {
|
|
return fmt.Errorf("%w: duplicate 'ID': %s", apierr.GetAPIError(apierr.ErrInvalidArgument), rule.ID)
|
|
}
|
|
ids[rule.ID] = struct{}{}
|
|
|
|
if len(rule.ID) > maxRuleIDLen {
|
|
return fmt.Errorf("%w: 'ID' value cannot be longer than %d characters", apierr.GetAPIError(apierr.ErrInvalidArgument), maxRuleIDLen)
|
|
}
|
|
|
|
if rule.Status != data.LifecycleStatusEnabled && rule.Status != data.LifecycleStatusDisabled {
|
|
return fmt.Errorf("%w: invalid lifecycle status: %s", apierr.GetAPIError(apierr.ErrMalformedXML), rule.Status)
|
|
}
|
|
|
|
if rule.AbortIncompleteMultipartUpload == nil && rule.Expiration == nil && rule.NonCurrentVersionExpiration == nil {
|
|
return fmt.Errorf("%w: at least one action needs to be specified in a rule", apierr.GetAPIError(apierr.ErrInvalidRequest))
|
|
}
|
|
|
|
if rule.AbortIncompleteMultipartUpload != nil {
|
|
if rule.AbortIncompleteMultipartUpload.DaysAfterInitiation != nil &&
|
|
*rule.AbortIncompleteMultipartUpload.DaysAfterInitiation <= 0 {
|
|
return fmt.Errorf("%w: days after initiation must be a positive integer", apierr.GetAPIError(apierr.ErrInvalidArgument))
|
|
}
|
|
|
|
if rule.Filter != nil && (rule.Filter.Tag != nil || (rule.Filter.And != nil && len(rule.Filter.And.Tags) > 0)) {
|
|
return fmt.Errorf("%w: abort incomplete multipart upload cannot be specified with tags", apierr.GetAPIError(apierr.ErrInvalidRequest))
|
|
}
|
|
}
|
|
|
|
if rule.Expiration != nil {
|
|
if rule.Expiration.ExpiredObjectDeleteMarker != nil {
|
|
if rule.Expiration.Days != nil || rule.Expiration.Date != "" {
|
|
return fmt.Errorf("%w: expired object delete marker cannot be specified with days or date", apierr.GetAPIError(apierr.ErrMalformedXML))
|
|
}
|
|
|
|
if rule.Filter != nil && (rule.Filter.Tag != nil || (rule.Filter.And != nil && len(rule.Filter.And.Tags) > 0)) {
|
|
return fmt.Errorf("%w: expired object delete marker cannot be specified with tags", apierr.GetAPIError(apierr.ErrInvalidRequest))
|
|
}
|
|
}
|
|
|
|
if rule.Expiration.Days != nil && *rule.Expiration.Days <= 0 {
|
|
return fmt.Errorf("%w: expiration days must be a positive integer", apierr.GetAPIError(apierr.ErrInvalidArgument))
|
|
}
|
|
|
|
if rule.Expiration.Date != "" {
|
|
parsedTime, err := time.Parse("2006-01-02T15:04:05Z", rule.Expiration.Date)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: invalid value of expiration date: %s", apierr.GetAPIError(apierr.ErrInvalidArgument), rule.Expiration.Date)
|
|
}
|
|
|
|
epoch, err := util.TimeToEpoch(ni, now, parsedTime)
|
|
if err != nil {
|
|
return fmt.Errorf("convert time to epoch: %w", err)
|
|
}
|
|
|
|
cfg.Rules[i].Expiration.Epoch = &epoch
|
|
}
|
|
}
|
|
|
|
if rule.NonCurrentVersionExpiration != nil {
|
|
if rule.NonCurrentVersionExpiration.NewerNonCurrentVersions != nil && rule.NonCurrentVersionExpiration.NonCurrentDays == nil {
|
|
return fmt.Errorf("%w: newer noncurrent versions cannot be specified without noncurrent days", apierr.GetAPIError(apierr.ErrMalformedXML))
|
|
}
|
|
|
|
if rule.NonCurrentVersionExpiration.NewerNonCurrentVersions != nil &&
|
|
(*rule.NonCurrentVersionExpiration.NewerNonCurrentVersions > maxNewerNoncurrentVersions ||
|
|
*rule.NonCurrentVersionExpiration.NewerNonCurrentVersions <= 0) {
|
|
return fmt.Errorf("%w: newer noncurrent versions must be a positive integer up to %d", apierr.GetAPIError(apierr.ErrInvalidArgument),
|
|
maxNewerNoncurrentVersions)
|
|
}
|
|
|
|
if rule.NonCurrentVersionExpiration.NonCurrentDays != nil && *rule.NonCurrentVersionExpiration.NonCurrentDays <= 0 {
|
|
return fmt.Errorf("%w: noncurrent days must be a positive integer", apierr.GetAPIError(apierr.ErrInvalidArgument))
|
|
}
|
|
}
|
|
|
|
if err := checkLifecycleRuleFilter(rule.Filter); err != nil {
|
|
return err
|
|
}
|
|
|
|
if rule.Filter != nil && rule.Filter.Prefix != "" && rule.Prefix != "" {
|
|
return fmt.Errorf("%w: rule cannot have two prefixes", apierr.GetAPIError(apierr.ErrMalformedXML))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkLifecycleRuleFilter(filter *data.LifecycleRuleFilter) error {
|
|
if filter == nil {
|
|
return nil
|
|
}
|
|
|
|
var fields int
|
|
|
|
if filter.And != nil {
|
|
fields++
|
|
for _, tag := range filter.And.Tags {
|
|
err := checkTag(tag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if filter.And.ObjectSizeLessThan != nil {
|
|
if *filter.And.ObjectSizeLessThan == 0 {
|
|
return fmt.Errorf("%w: the maximum object size must be more than 0", apierr.GetAPIError(apierr.ErrInvalidRequest))
|
|
}
|
|
|
|
if filter.And.ObjectSizeGreaterThan != nil && *filter.And.ObjectSizeLessThan <= *filter.And.ObjectSizeGreaterThan {
|
|
return fmt.Errorf("%w: the maximum object size must be larger than the minimum object size", apierr.GetAPIError(apierr.ErrInvalidRequest))
|
|
}
|
|
}
|
|
}
|
|
|
|
if filter.ObjectSizeGreaterThan != nil {
|
|
fields++
|
|
}
|
|
|
|
if filter.ObjectSizeLessThan != nil {
|
|
if *filter.ObjectSizeLessThan == 0 {
|
|
return fmt.Errorf("%w: the maximum object size must be more than 0", apierr.GetAPIError(apierr.ErrInvalidRequest))
|
|
}
|
|
fields++
|
|
}
|
|
|
|
if filter.Prefix != "" {
|
|
fields++
|
|
}
|
|
|
|
if filter.Tag != nil {
|
|
fields++
|
|
err := checkTag(*filter.Tag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if fields > 1 {
|
|
return fmt.Errorf("%w: filter cannot have more than one field", apierr.GetAPIError(apierr.ErrMalformedXML))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func getContentMD5(reader io.Reader) ([]byte, error) {
|
|
hash := md5.New()
|
|
_, err := io.Copy(hash, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hash.Sum(nil), nil
|
|
}
|