diff --git a/cmd/bucket-listobjects-handlers.go b/cmd/bucket-listobjects-handlers.go index d772ad68e..a5535425b 100644 --- a/cmd/bucket-listobjects-handlers.go +++ b/cmd/bucket-listobjects-handlers.go @@ -236,12 +236,20 @@ func (api objectAPIHandlers) ListObjectsV2Handler(w http.ResponseWriter, r *http return } - listObjectsV2 := objectAPI.ListObjectsV2 + var ( + listObjectsV2Info ListObjectsV2Info + err error + ) - // Inititate a list objects operation based on the input params. - // On success would return back ListObjectsInfo object to be - // marshaled into S3 compatible XML header. - listObjectsV2Info, err := listObjectsV2(ctx, bucket, prefix, token, delimiter, maxKeys, fetchOwner, startAfter) + if r.Header.Get(xMinIOExtract) == "true" && strings.Contains(prefix, archivePattern) { + // Inititate a list objects operation inside a zip file based in the input params + listObjectsV2Info, err = listObjectsV2InArchive(ctx, objectAPI, bucket, prefix, token, delimiter, maxKeys, fetchOwner, startAfter) + } else { + // Inititate a list objects operation based on the input params. + // On success would return back ListObjectsInfo object to be + // marshaled into S3 compatible XML header. + listObjectsV2Info, err = objectAPI.ListObjectsV2(ctx, bucket, prefix, token, delimiter, maxKeys, fetchOwner, startAfter) + } if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) return diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index abcb30325..57e51256a 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -309,20 +309,7 @@ func (api objectAPIHandlers) SelectObjectContentHandler(w http.ResponseWriter, r }) } -// GetObjectHandler - GET Object -// ---------- -// This implementation of the GET operation retrieves object. To use GET, -// you must have READ access to the object. -func (api objectAPIHandlers) GetObjectHandler(w http.ResponseWriter, r *http.Request) { - ctx := newContext(r, w, "GetObject") - - defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) - - objectAPI := api.ObjectAPI() - if objectAPI == nil { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL, guessIsBrowserReq(r)) - return - } +func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI ObjectLayer, bucket, object string, w http.ResponseWriter, r *http.Request) { if crypto.S3.IsRequested(r.Header) || crypto.S3KMS.IsRequested(r.Header) { // If SSE-S3 or SSE-KMS present -> AWS fails with undefined error writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrBadRequest), r.URL, guessIsBrowserReq(r)) return @@ -331,13 +318,6 @@ func (api objectAPIHandlers) GetObjectHandler(w http.ResponseWriter, r *http.Req writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrBadRequest), r.URL, guessIsBrowserReq(r)) return } - vars := mux.Vars(r) - bucket := vars["bucket"] - object, err := unescapePath(vars["object"]) - if err != nil { - writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) - return - } // get gateway encryption options opts, err := getOpts(ctx, r, bucket, object) @@ -555,19 +535,37 @@ func (api objectAPIHandlers) GetObjectHandler(w http.ResponseWriter, r *http.Req }) } -// HeadObjectHandler - HEAD Object -// ----------- -// The HEAD operation retrieves metadata from an object without returning the object itself. -func (api objectAPIHandlers) HeadObjectHandler(w http.ResponseWriter, r *http.Request) { - ctx := newContext(r, w, "HeadObject") +// GetObjectHandler - GET Object +// ---------- +// This implementation of the GET operation retrieves object. To use GET, +// you must have READ access to the object. +func (api objectAPIHandlers) GetObjectHandler(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "GetObject") defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) objectAPI := api.ObjectAPI() if objectAPI == nil { - writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(ErrServerNotInitialized)) + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL, guessIsBrowserReq(r)) return } + + vars := mux.Vars(r) + bucket := vars["bucket"] + object, err := unescapePath(vars["object"]) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + if r.Header.Get(xMinIOExtract) == "true" && strings.Contains(object, archivePattern) { + api.getObjectInArchiveFileHandler(ctx, objectAPI, bucket, object, w, r) + } else { + api.getObjectHandler(ctx, objectAPI, bucket, object, w, r) + } +} + +func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI ObjectLayer, bucket, object string, w http.ResponseWriter, r *http.Request) { if crypto.S3.IsRequested(r.Header) || crypto.S3KMS.IsRequested(r.Header) { // If SSE-S3 or SSE-KMS present -> AWS fails with undefined error writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(ErrBadRequest)) return @@ -576,13 +574,6 @@ func (api objectAPIHandlers) HeadObjectHandler(w http.ResponseWriter, r *http.Re writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrBadRequest), r.URL, guessIsBrowserReq(r)) return } - vars := mux.Vars(r) - bucket := vars["bucket"] - object, err := unescapePath(vars["object"]) - if err != nil { - writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) - return - } getObjectInfo := objectAPI.GetObjectInfo if api.CacheAPI() != nil { @@ -768,6 +759,34 @@ func (api objectAPIHandlers) HeadObjectHandler(w http.ResponseWriter, r *http.Re }) } +// HeadObjectHandler - HEAD Object +// ----------- +// The HEAD operation retrieves metadata from an object without returning the object itself. +func (api objectAPIHandlers) HeadObjectHandler(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "HeadObject") + defer logger.AuditLog(ctx, w, r, mustGetClaimsFromToken(r)) + + objectAPI := api.ObjectAPI() + if objectAPI == nil { + writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(ErrServerNotInitialized)) + return + } + + vars := mux.Vars(r) + bucket := vars["bucket"] + object, err := unescapePath(vars["object"]) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + if r.Header.Get(xMinIOExtract) == "true" && strings.Contains(object, archivePattern) { + api.headObjectInArchiveFileHandler(ctx, objectAPI, bucket, object, w, r) + } else { + api.headObjectHandler(ctx, objectAPI, bucket, object, w, r) + } +} + // Extract metadata relevant for an CopyObject operation based on conditional // header values specified in X-Amz-Metadata-Directive. func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta map[string]string) (map[string]string, error) { @@ -1704,6 +1723,14 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req return } + if r.Header.Get(xMinIOExtract) == "true" && strings.HasSuffix(object, archiveExt) { + opts := ObjectOptions{VersionID: objInfo.VersionID, MTime: objInfo.ModTime} + if _, err := updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, object, opts); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + } + if kind, encrypted := crypto.IsEncrypted(objInfo.UserDefined); encrypted { switch kind { case crypto.S3: @@ -3125,6 +3152,14 @@ func (api objectAPIHandlers) CompleteMultipartUploadHandler(w http.ResponseWrite } } + if r.Header.Get(xMinIOExtract) == "true" && strings.HasSuffix(object, archiveExt) { + opts := ObjectOptions{VersionID: objInfo.VersionID, MTime: objInfo.ModTime} + if _, err := updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, object, opts); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + } + setPutObjHeaders(w, objInfo, false) if replicate, sync := mustReplicate(ctx, r, bucket, object, getMustReplicateOptions(objInfo, replication.ObjectReplicationType)); replicate { scheduleReplication(ctx, objInfo.Clone(), objectAPI, sync, replication.ObjectReplicationType) diff --git a/cmd/s3-zip-handlers.go b/cmd/s3-zip-handlers.go new file mode 100644 index 000000000..3d10c1e46 --- /dev/null +++ b/cmd/s3-zip-handlers.go @@ -0,0 +1,506 @@ +// Copyright (c) 2015-2021 MinIO, Inc. +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + stdioutil "io/ioutil" + "net/http" + "sort" + "strings" + + "github.com/minio/minio/internal/crypto" + "github.com/minio/minio/internal/ioutil" + "github.com/minio/minio/internal/logger" + xnet "github.com/minio/minio/internal/net" + "github.com/minio/pkg/bucket/policy" + "github.com/minio/zipindex" +) + +const ( + archiveType = "zip" + archiveExt = "." + archiveType // ".zip" + archiveSeparator = "/" + archivePattern = archiveExt + archiveSeparator // ".zip/" + archiveTypeMetadataKey = ReservedMetadataPrefixLower + "archive-type" // "x-minio-internal-archive-type" + archiveInfoMetadataKey = ReservedMetadataPrefixLower + "archive-info" // "x-minio-internal-archive-info" + + // Peek into a zip archive + xMinIOExtract = "x-minio-extract" +) + +// splitZipExtensionPath splits the S3 path to the zip file and the path inside the zip: +// e.g /path/to/archive.zip/backup-2021/myimage.png => /path/to/archive.zip, backup/myimage.png +func splitZipExtensionPath(input string) (zipPath, object string, err error) { + idx := strings.Index(input, archivePattern) + if idx < 0 { + // Should never happen + return "", "", errors.New("unable to parse zip path") + } + return input[:idx+len(archivePattern)-1], input[idx+len(archivePattern):], nil +} + +// getObjectInArchiveFileHandler - GET Object in the archive file +func (api objectAPIHandlers) getObjectInArchiveFileHandler(ctx context.Context, objectAPI ObjectLayer, bucket, object string, w http.ResponseWriter, r *http.Request) { + if crypto.S3.IsRequested(r.Header) || crypto.S3KMS.IsRequested(r.Header) { // If SSE-S3 or SSE-KMS present -> AWS fails with undefined error + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrBadRequest), r.URL, guessIsBrowserReq(r)) + return + } + if _, ok := crypto.IsRequested(r.Header); !objectAPI.IsEncryptionSupported() && ok { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrBadRequest), r.URL, guessIsBrowserReq(r)) + return + } + + zipPath, object, err := splitZipExtensionPath(object) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + // get gateway encryption options + opts, err := getOpts(ctx, r, bucket, zipPath) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + getObjectInfo := objectAPI.GetObjectInfo + if api.CacheAPI() != nil { + getObjectInfo = api.CacheAPI().GetObjectInfo + } + + // Check for auth type to return S3 compatible error. + // type to return the correct error (NoSuchKey vs AccessDenied) + if s3Error := checkRequestAuthType(ctx, r, policy.GetObjectAction, bucket, zipPath); s3Error != ErrNone { + if getRequestAuthType(r) == authTypeAnonymous { + // As per "Permission" section in + // https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html + // If the object you request does not exist, + // the error Amazon S3 returns depends on + // whether you also have the s3:ListBucket + // permission. + // * If you have the s3:ListBucket permission + // on the bucket, Amazon S3 will return an + // HTTP status code 404 ("no such key") + // error. + // * if you don’t have the s3:ListBucket + // permission, Amazon S3 will return an HTTP + // status code 403 ("access denied") error.` + if globalPolicySys.IsAllowed(policy.Args{ + Action: policy.ListBucketAction, + BucketName: bucket, + ConditionValues: getConditionValues(r, "", "", nil), + IsOwner: false, + }) { + _, err = getObjectInfo(ctx, bucket, zipPath, opts) + if toAPIError(ctx, err).Code == "NoSuchKey" { + s3Error = ErrNoSuchKey + } + } + } + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL, guessIsBrowserReq(r)) + return + } + + // Validate pre-conditions if any. + opts.CheckPrecondFn = func(oi ObjectInfo) bool { + if objectAPI.IsEncryptionSupported() { + if _, err := DecryptObjectInfo(&oi, r); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return true + } + } + + return checkPreconditions(ctx, w, r, oi, opts) + } + + zipObjInfo, err := getObjectInfo(ctx, bucket, zipPath, opts) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + var zipInfo []byte + + if z, ok := zipObjInfo.UserDefined[archiveInfoMetadataKey]; ok { + zipInfo = []byte(z) + } else { + zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, opts) + } + + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + file, err := zipindex.FindSerialized(zipInfo, object) + if err != nil { + if err == io.EOF { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNoSuchKey), r.URL, guessIsBrowserReq(r)) + } else { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + } + return + } + + // New object info + fileObjInfo := ObjectInfo{ + Bucket: bucket, + Name: object, + Size: int64(file.UncompressedSize64), + ModTime: zipObjInfo.ModTime, + } + + var rc io.ReadCloser + + if file.UncompressedSize64 > 0 { + rs := &HTTPRangeSpec{Start: file.Offset, End: file.Offset + int64(file.UncompressedSize64) - 1} + gr, err := objectAPI.GetObjectNInfo(ctx, bucket, zipPath, rs, nil, readLock, opts) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + defer gr.Close() + rc, err = file.Open(gr) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + } else { + rc = stdioutil.NopCloser(bytes.NewReader([]byte{})) + } + + defer rc.Close() + + if err = setObjectHeaders(w, fileObjInfo, nil, opts); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + setHeadGetRespHeaders(w, r.URL.Query()) + + httpWriter := ioutil.WriteOnClose(w) + + // Write object content to response body + if _, err = io.Copy(httpWriter, rc); err != nil { + if !httpWriter.HasWritten() { + // write error response only if no data or headers has been written to client yet + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + if !xnet.IsNetworkOrHostDown(err, true) { // do not need to log disconnected clients + logger.LogIf(ctx, fmt.Errorf("Unable to write all the data to client %w", err)) + } + return + } + + if err = httpWriter.Close(); err != nil { + if !httpWriter.HasWritten() { // write error response only if no data or headers has been written to client yet + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + if !xnet.IsNetworkOrHostDown(err, true) { // do not need to log disconnected clients + logger.LogIf(ctx, fmt.Errorf("Unable to write all the data to client %w", err)) + } + return + } +} + +// listObjectsV2InArchive generates S3 listing result ListObjectsV2Info from zip file, all parameters are already validated by the caller. +func listObjectsV2InArchive(ctx context.Context, objectAPI ObjectLayer, bucket, prefix, token, delimiter string, maxKeys int, fetchOwner bool, startAfter string) (ListObjectsV2Info, error) { + zipPath, _, err := splitZipExtensionPath(prefix) + if err != nil { + // Return empty listing + return ListObjectsV2Info{}, nil + } + + zipObjInfo, err := objectAPI.GetObjectInfo(ctx, bucket, zipPath, ObjectOptions{}) + if err != nil { + // Return empty listing + return ListObjectsV2Info{}, nil + } + + var zipInfo []byte + + if z, ok := zipObjInfo.UserDefined[archiveInfoMetadataKey]; ok { + zipInfo = []byte(z) + } else { + // Always update the latest version + zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, ObjectOptions{}) + } + + if err != nil { + return ListObjectsV2Info{}, err + } + + files, err := zipindex.DeserializeFiles(zipInfo) + if err != nil { + return ListObjectsV2Info{}, err + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Name < files[j].Name + }) + + var ( + count int + isTruncated bool + nextToken string + listObjectsInfo ListObjectsV2Info + ) + + // Always set this + listObjectsInfo.ContinuationToken = token + + // Open and iterate through the files in the archive. + for _, file := range files { + objName := zipObjInfo.Name + archiveSeparator + file.Name + if objName <= startAfter || objName <= token { + continue + } + if strings.HasPrefix(objName, prefix) { + if count == maxKeys { + isTruncated = true + break + } + if delimiter != "" { + i := strings.Index(objName[len(prefix):], delimiter) + if i >= 0 { + commonPrefix := objName[:len(prefix)+i+1] + if len(listObjectsInfo.Prefixes) == 0 || commonPrefix != listObjectsInfo.Prefixes[len(listObjectsInfo.Prefixes)-1] { + listObjectsInfo.Prefixes = append(listObjectsInfo.Prefixes, commonPrefix) + count++ + } + goto next + } + } + listObjectsInfo.Objects = append(listObjectsInfo.Objects, ObjectInfo{ + Bucket: bucket, + Name: objName, + Size: int64(file.UncompressedSize64), + ModTime: zipObjInfo.ModTime, + }) + count++ + } + next: + nextToken = objName + } + + if isTruncated { + listObjectsInfo.IsTruncated = true + listObjectsInfo.NextContinuationToken = nextToken + } + + return listObjectsInfo, nil +} + +// getFilesFromZIPObject reads a partial stream of a zip file to build the zipindex.Files index +func getFilesListFromZIPObject(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) (zipindex.Files, ObjectInfo, error) { + var size = 1 << 20 + var objSize int64 + for { + rs := &HTTPRangeSpec{IsSuffixLength: true, Start: int64(-size)} + gr, err := objectAPI.GetObjectNInfo(ctx, bucket, object, rs, nil, readLock, opts) + if err != nil { + return nil, ObjectInfo{}, err + } + b, err := stdioutil.ReadAll(gr) + if err != nil { + gr.Close() + return nil, ObjectInfo{}, err + } + gr.Close() + if size > len(b) { + size = len(b) + } + + // Calculate the object real size if encrypted + if _, ok := crypto.IsEncrypted(gr.ObjInfo.UserDefined); ok { + objSize, err = gr.ObjInfo.DecryptedSize() + if err != nil { + return nil, ObjectInfo{}, err + } + } else { + objSize = gr.ObjInfo.Size + } + + files, err := zipindex.ReadDir(b[len(b)-size:], objSize, nil) + if err == nil { + return files, gr.ObjInfo, nil + } + var terr zipindex.ErrNeedMoreData + if errors.As(err, &terr) { + size = int(terr.FromEnd) + if size <= 0 || size > 100<<20 { + return nil, ObjectInfo{}, errors.New("zip directory too large") + } + } else { + return nil, ObjectInfo{}, err + } + } +} + +// headObjectInArchiveFileHandler - HEAD Object in an archive file +func (api objectAPIHandlers) headObjectInArchiveFileHandler(ctx context.Context, objectAPI ObjectLayer, bucket, object string, w http.ResponseWriter, r *http.Request) { + if crypto.S3.IsRequested(r.Header) || crypto.S3KMS.IsRequested(r.Header) { // If SSE-S3 or SSE-KMS present -> AWS fails with undefined error + writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(ErrBadRequest)) + return + } + if _, ok := crypto.IsRequested(r.Header); !objectAPI.IsEncryptionSupported() && ok { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrBadRequest), r.URL, guessIsBrowserReq(r)) + return + } + + zipPath, object, err := splitZipExtensionPath(object) + if err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(ctx, err)) + return + } + + getObjectInfo := objectAPI.GetObjectInfo + if api.CacheAPI() != nil { + getObjectInfo = api.CacheAPI().GetObjectInfo + } + + opts, err := getOpts(ctx, r, bucket, zipPath) + if err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(ctx, err)) + return + } + + if s3Error := checkRequestAuthType(ctx, r, policy.GetObjectAction, bucket, zipPath); s3Error != ErrNone { + if getRequestAuthType(r) == authTypeAnonymous { + // As per "Permission" section in + // https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html + // If the object you request does not exist, + // the error Amazon S3 returns depends on + // whether you also have the s3:ListBucket + // permission. + // * If you have the s3:ListBucket permission + // on the bucket, Amazon S3 will return an + // HTTP status code 404 ("no such key") + // error. + // * if you don’t have the s3:ListBucket + // permission, Amazon S3 will return an HTTP + // status code 403 ("access denied") error.` + if globalPolicySys.IsAllowed(policy.Args{ + Action: policy.ListBucketAction, + BucketName: bucket, + ConditionValues: getConditionValues(r, "", "", nil), + IsOwner: false, + }) { + _, err = getObjectInfo(ctx, bucket, zipPath, opts) + if toAPIError(ctx, err).Code == "NoSuchKey" { + s3Error = ErrNoSuchKey + } + } + } + writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(s3Error)) + return + } + + var rs *HTTPRangeSpec + + // Validate pre-conditions if any. + opts.CheckPrecondFn = func(oi ObjectInfo) bool { + return checkPreconditions(ctx, w, r, oi, opts) + } + + zipObjInfo, err := getObjectInfo(ctx, bucket, zipPath, opts) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + var zipInfo []byte + + if z, ok := zipObjInfo.UserDefined[archiveInfoMetadataKey]; ok { + zipInfo = []byte(z) + } else { + zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, opts) + } + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + return + } + + file, err := zipindex.FindSerialized(zipInfo, object) + if err != nil { + if err == io.EOF { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrNoSuchKey), r.URL, guessIsBrowserReq(r)) + } else { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL, guessIsBrowserReq(r)) + } + return + } + + objInfo := ObjectInfo{ + Bucket: bucket, + Name: file.Name, + Size: int64(file.UncompressedSize64), + ModTime: zipObjInfo.ModTime, + } + + // Set standard object headers. + if err = setObjectHeaders(w, objInfo, nil, opts); err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(ctx, err)) + return + } + + // Set any additional requested response headers. + setHeadGetRespHeaders(w, r.URL.Query()) + + // Successful response. + if rs != nil { + w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) + } +} + +// Update the passed zip object metadata with the zip contents info, file name, modtime, size, etc.. +func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, error) { + files, srcInfo, err := getFilesListFromZIPObject(ctx, objectAPI, bucket, object, opts) + if err != nil { + return nil, err + } + files.OptimizeSize() + zipInfo, err := files.Serialize() + if err != nil { + return nil, err + } + + srcInfo.UserDefined[archiveTypeMetadataKey] = archiveType + srcInfo.UserDefined[archiveInfoMetadataKey] = string(zipInfo) + srcInfo.metadataOnly = true + + // Always update the same version id & modtime + + // Passing opts twice as source & destination options will update the metadata + // of the same object version to avoid creating a new version. + _, err = objectAPI.CopyObject(ctx, bucket, object, bucket, object, srcInfo, opts, opts) + if err != nil { + return nil, err + } + + return zipInfo, nil +} diff --git a/docs/extensions/s3zip/README.md b/docs/extensions/s3zip/README.md new file mode 100644 index 000000000..db5add1f1 --- /dev/null +++ b/docs/extensions/s3zip/README.md @@ -0,0 +1,40 @@ +# Perform S3 operations in a ZIP content[![Slack](https://slack.min.io/slack?type=svg)](https://slack.min.io) [![Docker Pulls](https://img.shields.io/docker/pulls/minio/minio.svg?maxAge=604800)](https://hub.docker.com/r/minio/minio/) + +### Overview + +MinIO implements an S3 extension to list, stat and download files inside a ZIP file stored in any bucket. A perfect use case scenario is when you have a lot of small files archived in multiple ZIP files. Uploading them is faster than uploading small files individually. Besides, your S3 applications will be able to access to the data with little performance overhead. + +The main limitation is that to update or delete content of a file inside a ZIP file the entire ZIP file must be replaced. + +### How to enable S3 ZIP behavior ? + +Ensure to set the following header `x-minio-extract` to `true` in your S3 requests. + +### How to access to files inside a ZIP archive + +Accessing to contents inside an archive can be done using regular S3 API with a modified request path. You just need to append the path of the content inside the archive to the path of the archive itself. + +e.g.: +To download `2021/taxes.csv` archived in `financial.zip` and stored under a bucket named `company-data`, you can issue a GET request using the following path 'company-data/financial.zip/2021/taxes.csv` + +### Contents properties + +All properties except the file size are tied to the zip file. This means that modification date, headers, tags, etc. can only be set for the zip file as a whole. In similar fashion, replication will replicate the zip file as a whole and not individual files. + +### Code Examples + +[Using minio-go library](https://github.com/minio/minio/blob/master/docs/extensions/s3zip/examples/minio-go/main.go) +[Using AWS JS SDK v2](https://github.com/minio/minio/blob/master/docs/extensions/s3zip/examples/aws-js/main.js) +[Using boto3](https://github.com/minio/minio/blob/master/docs/extensions/s3zip/examples/boto3/main.py) + + +### Requirements and limits +- ListObjectsV2 can only list the most recent ZIP archive version of your object, applicable only for versioned buckets. +- ListObjectsV2 API calls must be used to list zip file content. +- Names inside ZIP files are kept unmodified, but some may lead to invalid paths. See [Object key naming guidelines](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html) on safe names. +- This API behavior is limited for following **read** operations on files inside a zip archive: + - `HeadObject` + - `GetObject` + - `ListObjectsV2` +- A maximum of 100,000 files inside a single ZIP archive is recommended for best performance and memory usage trade-off. +- If the ZIP file directory isn't located within the last 100MB the file will not be parsed. diff --git a/docs/extensions/s3zip/examples/aws-js/main.js b/docs/extensions/s3zip/examples/aws-js/main.js new file mode 100644 index 000000000..02b0571f9 --- /dev/null +++ b/docs/extensions/s3zip/examples/aws-js/main.js @@ -0,0 +1,31 @@ + +var AWS = require('aws-sdk'); + +var s3 = new AWS.S3({ + accessKeyId: 'YOUR-ACCESSKEYID' , + secretAccessKey: 'YOUR-SECRETACCESSKEY' , + endpoint: 'http://127.0.0.1:9000' , + s3ForcePathStyle: true, + signatureVersion: 'v4' +}); + +// List all contents stored in the zip archive +s3.listObjectsV2({Bucket : 'your-bucket', Prefix: 'path/to/file.zip/'}). + on('build', function(req) { req.httpRequest.headers['X-Minio-Extract'] = 'true'; }). + send(function(err, data) { + if (err) { + console.log("Error", err); + } else { + console.log("Success", data); + } + }); + + +// Download a file in the archive and store it in /tmp/data.csv +var file = require('fs').createWriteStream('/tmp/data.csv'); +s3.getObject({Bucket: 'your-bucket', Key: 'path/to/file.zip/data.csv'}). + on('build', function(req) { req.httpRequest.headers['X-Minio-Extract'] = 'true'; }). + on('httpData', function(chunk) { file.write(chunk); }). + on('httpDone', function() { file.end(); }). + send(); + diff --git a/docs/extensions/s3zip/examples/aws-js/package.json b/docs/extensions/s3zip/examples/aws-js/package.json new file mode 100644 index 000000000..29f2bd2d6 --- /dev/null +++ b/docs/extensions/s3zip/examples/aws-js/package.json @@ -0,0 +1,8 @@ +{ + "name": "s3-zip-example", + "version": "1.0.0", + "main": "main.js", + "dependencies": { + "aws-sdk": "^2.924.0" + } +} diff --git a/docs/extensions/s3zip/examples/boto3/main.py b/docs/extensions/s3zip/examples/boto3/main.py new file mode 100644 index 000000000..f45710a19 --- /dev/null +++ b/docs/extensions/s3zip/examples/boto3/main.py @@ -0,0 +1,25 @@ +#!/usr/bin/env/python + +import boto3 +from botocore.client import Config + +s3 = boto3.client('s3', + endpoint_url='http://localhost:9000', + aws_access_key_id='YOUR-ACCESSKEYID', + aws_secret_access_key='YOUR-SECRETACCESSKEY', + config=Config(signature_version='s3v4'), + region_name='us-east-1') + + +def _add_header(request, **kwargs): + request.headers.add_header('x-minio-extract', 'true') +event_system = s3.meta.events +event_system.register_first('before-sign.s3.*', _add_header) + +# List zip contents +response = s3.list_objects_v2(Bucket="your-bucket", Prefix="path/to/file.zip/") +print(response) + +# Downlaod data.csv stored in the zip file +s3.download_file(Bucket='your-bucket', Key='path/to/file.zip/data.csv', Filename='/tmp/data.csv') + diff --git a/docs/extensions/s3zip/examples/minio-go/main.go b/docs/extensions/s3zip/examples/minio-go/main.go new file mode 100644 index 000000000..ca4acbeff --- /dev/null +++ b/docs/extensions/s3zip/examples/minio-go/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "io" + "log" + "net/http" + "os" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +type s3ExtensionTransport struct { + tr http.RoundTripper +} + +func (t *s3ExtensionTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("x-minio-extract", "true") + return t.tr.RoundTrip(req) +} + +func main() { + tr, _ := minio.DefaultTransport(false) + + s3Client, err := minio.New("minio-server-address:9000", &minio.Options{ + Creds: credentials.NewStaticV4("access-key", "secret-key", ""), + Transport: &s3ExtensionTransport{tr}, + }) + if err != nil { + log.Fatalln(err) + } + + // Download API.md from the archive + rd, err := s3Client.GetObject(context.Background(), "your-bucket", "path/to/file.zip/data.csv", minio.GetObjectOptions{}) + if err != nil { + log.Fatalln(err) + } + _, err = io.Copy(os.Stdout, rd) + if err != nil { + log.Fatalln(err) + } + + return +} diff --git a/go.mod b/go.mod index 1d21c3f01..34537b928 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( github.com/minio/sha256-simd v1.0.0 github.com/minio/simdjson-go v0.2.1 github.com/minio/sio v0.3.0 + github.com/minio/zipindex v0.2.0 github.com/mitchellh/go-homedir v1.1.0 github.com/montanaflynn/stats v0.5.0 github.com/nats-io/nats-server/v2 v2.1.9 diff --git a/go.sum b/go.sum index 95819fb66..47e91a463 100644 --- a/go.sum +++ b/go.sum @@ -506,6 +506,8 @@ github.com/minio/simdjson-go v0.2.1/go.mod h1:JPUSkRykfSPS+AhO0YPA1h0l5vY7NqrF4z github.com/minio/sio v0.2.1/go.mod h1:8b0yPp2avGThviy/+OCJBI6OMpvxoUuiLvE6F1lebhw= github.com/minio/sio v0.3.0 h1:syEFBewzOMOYVzSTFpp1MqpSZk8rUNbz8VIIc+PNzus= github.com/minio/sio v0.3.0/go.mod h1:8b0yPp2avGThviy/+OCJBI6OMpvxoUuiLvE6F1lebhw= +github.com/minio/zipindex v0.2.0 h1:iqgIhPkYnZ6fNd6q8trtJc8+mtKJNFTaxXlNw4n0We8= +github.com/minio/zipindex v0.2.0/go.mod h1:s+b/Qyw9JtSEnYfaM4ASOWNO2xGnXCfzQ+SWAzVkVZc= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -696,6 +698,7 @@ github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV github.com/tidwall/sjson v1.1.6 h1:8fDdlahON04OZBlTQCIatW8FstSFJz8oxidj5h0rmSQ= github.com/tidwall/sjson v1.1.6/go.mod h1:KN3FZ7odvXIHPbJdhNorK/M9lWweVUbXsXXhrJ/kGOA= github.com/tinylib/msgp v1.1.3/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= +github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg= github.com/tinylib/msgp v1.1.6-0.20210521143832-0becd170c402 h1:x5VlSgDgIGXNegkO4gigpYmb/RFkKGgy12Kkrbif7XE= github.com/tinylib/msgp v1.1.6-0.20210521143832-0becd170c402/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw= github.com/tklauser/go-sysconf v0.3.4/go.mod h1:Cl2c8ZRWfHD5IrfHo9VN+FX9kCFjIOyVklgXycLB6ek= @@ -705,6 +708,7 @@ github.com/tklauser/numcpus v0.2.1/go.mod h1:9aU+wOc6WjUIZEwWMP62PL/41d65P+iks1g github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=