From 9bfdceca0813ebc51257b703478644b497940cc8 Mon Sep 17 00:00:00 2001 From: Andrej Mickov Date: Fri, 13 Mar 2026 01:29:29 +0100 Subject: [PATCH] HOTFIX: Copy object and encoding fixed --- api/api.go | 134 +++++++++++++++++++++++++++++++++++++++++---- auth/service.go | 23 ++++++++ models/models.go | 7 +++ service/service.go | 32 +++++++++++ 4 files changed, 185 insertions(+), 11 deletions(-) diff --git a/api/api.go b/api/api.go index 33f9b5c..3c98517 100644 --- a/api/api.go +++ b/api/api.go @@ -139,11 +139,83 @@ func validateObjectKey(key string) *s3APIError { return nil } +func objectKeyFromRequest(r *http.Request) (string, *s3APIError) { + rawKey := rawObjectKeyFromRequest(r) + key, err := normalizeObjectKey(rawKey) + if err != nil { + apiErr := s3ErrInvalidArgument + return "", &apiErr + } + if apiErr := validateObjectKey(key); apiErr != nil { + return "", apiErr + } + return key, nil +} + +func rawObjectKeyFromRequest(r *http.Request) string { + if r == nil || r.URL == nil { + return "" + } + bucket := chi.URLParam(r, "bucket") + if bucket == "" { + return chi.URLParam(r, "*") + } + escapedPath := r.URL.EscapedPath() + prefix := "/" + bucket + "/" + if strings.HasPrefix(escapedPath, prefix) { + return strings.TrimPrefix(escapedPath, prefix) + } + return chi.URLParam(r, "*") +} + +func normalizeObjectKey(raw string) (string, error) { + if raw == "" { + return "", nil + } + return url.PathUnescape(raw) +} + +func parseCopySource(raw string) (string, string, error) { + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "/") + if idx := strings.IndexByte(raw, '?'); idx >= 0 { + raw = raw[:idx] + } + bucket, rawKey, found := strings.Cut(raw, "/") + if !found || strings.TrimSpace(bucket) == "" || rawKey == "" { + return "", "", errors.New("invalid copy source") + } + key, err := normalizeObjectKey(rawKey) + if err != nil { + return "", "", err + } + if apiErr := validateObjectKey(key); apiErr != nil { + return "", "", errors.New(apiErr.Code) + } + return bucket, key, nil +} + +func (h *Handler) authorizeCopySource(r *http.Request, bucket, key string) error { + if h.authSvc == nil || !h.authSvc.Config().Enabled { + return nil + } + + authCtx, ok := auth.GetRequestContext(r.Context()) + if !ok || !authCtx.Authenticated { + return auth.ErrAccessDenied + } + + return h.authSvc.Authorize(authCtx.AccessKeyID, auth.RequestTarget{ + Action: auth.ActionGetObject, + Bucket: bucket, + Key: key, + }) +} + func (h *Handler) handleGetObject(w http.ResponseWriter, r *http.Request) { bucket := chi.URLParam(r, "bucket") - key := chi.URLParam(r, "*") - - if apiErr := validateObjectKey(key); apiErr != nil { + key, apiErr := objectKeyFromRequest(r) + if apiErr != nil { writeS3Error(w, r, *apiErr, r.URL.Path) return } @@ -200,8 +272,8 @@ func (h *Handler) handleGetObject(w http.ResponseWriter, r *http.Request) { func (h *Handler) handlePostObject(w http.ResponseWriter, r *http.Request) { bucket := chi.URLParam(r, "bucket") - key := chi.URLParam(r, "*") - if apiErr := validateObjectKey(key); apiErr != nil { + key, apiErr := objectKeyFromRequest(r) + if apiErr != nil { writeS3Error(w, r, *apiErr, r.URL.Path) return } @@ -276,8 +348,8 @@ func (h *Handler) handlePostObject(w http.ResponseWriter, r *http.Request) { func (h *Handler) handlePutObject(w http.ResponseWriter, r *http.Request) { bucket := chi.URLParam(r, "bucket") - key := chi.URLParam(r, "*") - if apiErr := validateObjectKey(key); apiErr != nil { + key, apiErr := objectKeyFromRequest(r) + if apiErr != nil { writeS3Error(w, r, *apiErr, r.URL.Path) return } @@ -290,6 +362,10 @@ func (h *Handler) handlePutObject(w http.ResponseWriter, r *http.Request) { writeS3Error(w, r, s3ErrInvalidPart, r.URL.Path) return } + if strings.TrimSpace(r.Header.Get("x-amz-copy-source")) != "" { + writeS3Error(w, r, s3ErrNotImplemented, r.URL.Path) + return + } partNumber, err := strconv.Atoi(partNumberRaw) if err != nil { @@ -334,6 +410,42 @@ func (h *Handler) handlePutObject(w http.ResponseWriter, r *http.Request) { } } + if copySourceRaw := strings.TrimSpace(r.Header.Get("x-amz-copy-source")); copySourceRaw != "" { + srcBucket, srcKey, err := parseCopySource(copySourceRaw) + if err != nil { + writeS3Error(w, r, s3ErrInvalidArgument, r.URL.Path) + return + } + if err := h.authorizeCopySource(r, srcBucket, srcKey); err != nil { + writeMappedS3Error(w, r, err) + return + } + + manifest, err := h.svc.CopyObject(srcBucket, srcKey, bucket, key) + if err != nil { + writeMappedS3Error(w, r, err) + return + } + + response := models.CopyObjectResult{ + Xmlns: "http://s3.amazonaws.com/doc/2006-03-01/", + LastModified: time.Unix(manifest.CreatedAt, 0).UTC().Format("2006-01-02T15:04:05.000Z"), + ETag: `"` + manifest.ETag + `"`, + } + payload, err := xml.MarshalIndent(response, "", " ") + if err != nil { + writeMappedS3Error(w, r, err) + return + } + + w.Header().Set("Content-Type", "application/xml; charset=utf-8") + w.Header().Set("ETag", `"`+manifest.ETag+`"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(xml.Header)) + _, _ = w.Write(payload) + return + } + contentType := r.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" @@ -617,8 +729,8 @@ func (h *Handler) handlePostBucket(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleDeleteObject(w http.ResponseWriter, r *http.Request) { bucket := chi.URLParam(r, "bucket") - key := chi.URLParam(r, "*") - if apiErr := validateObjectKey(key); apiErr != nil { + key, apiErr := objectKeyFromRequest(r) + if apiErr != nil { writeS3Error(w, r, *apiErr, r.URL.Path) return } @@ -654,8 +766,8 @@ func (h *Handler) handleHeadBucket(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleHeadObject(w http.ResponseWriter, r *http.Request) { bucket := chi.URLParam(r, "bucket") - key := chi.URLParam(r, "*") - if apiErr := validateObjectKey(key); apiErr != nil { + key, apiErr := objectKeyFromRequest(r) + if apiErr != nil { writeS3Error(w, r, *apiErr, r.URL.Path) return } diff --git a/auth/service.go b/auth/service.go index 8ed0587..9a5b01a 100644 --- a/auth/service.go +++ b/auth/service.go @@ -205,6 +205,29 @@ func (s *Service) AuthenticateRequest(r *http.Request) (RequestContext, error) { }, nil } +func (s *Service) Authorize(accessKeyID string, target RequestTarget) error { + if !s.cfg.Enabled { + return nil + } + + accessKeyID = strings.TrimSpace(accessKeyID) + if accessKeyID == "" { + return ErrAccessDenied + } + if target.Action == "" { + return ErrAccessDenied + } + + policy, err := s.store.GetAuthPolicy(accessKeyID) + if err != nil { + return ErrAccessDenied + } + if !isAllowed(policy, target) { + return ErrAccessDenied + } + return nil +} + func (s *Service) CreateUser(input CreateUserInput) (*CreateUserResult, error) { if !s.cfg.Enabled { return nil, ErrAuthNotEnabled diff --git a/models/models.go b/models/models.go index 4b0d38a..f89ed23 100644 --- a/models/models.go +++ b/models/models.go @@ -158,6 +158,13 @@ type CompleteMultipartUploadResult struct { Location string `xml:"Location,omitempty"` } +type CopyObjectResult struct { + XMLName xml.Name `xml:"CopyObjectResult"` + Xmlns string `xml:"xmlns,attr,omitempty"` + LastModified string `xml:"LastModified"` + ETag string `xml:"ETag"` +} + type ListPartsResult struct { XMLName xml.Name `xml:"ListPartsResult"` Xmlns string `xml:"xmlns,attr"` diff --git a/service/service.go b/service/service.go index 9d7db43..65dcead 100644 --- a/service/service.go +++ b/service/service.go @@ -104,6 +104,38 @@ func (s *ObjectService) PutObject(bucket, key, contentType string, input io.Read return manifest, nil } +func (s *ObjectService) CopyObject(srcBucket, srcKey, dstBucket, dstKey string) (*models.ObjectManifest, error) { + start := time.Now() + success := false + defer func() { + metrics.Default.ObserveService("copy_object", time.Since(start), success) + }() + + unlock := s.acquireGCRLock() + defer unlock() + + source, err := s.metadata.GetManifest(srcBucket, srcKey) + if err != nil { + return nil, err + } + + manifest := &models.ObjectManifest{ + Bucket: dstBucket, + Key: dstKey, + Size: source.Size, + ContentType: source.ContentType, + ETag: source.ETag, + Chunks: append([]string(nil), source.Chunks...), + CreatedAt: time.Now().Unix(), + } + if err := s.metadata.PutManifest(manifest); err != nil { + return nil, err + } + + success = true + return manifest, nil +} + func (s *ObjectService) GetObject(bucket, key string) (io.ReadCloser, *models.ObjectManifest, error) { start := time.Now()