This is an automated email from the ASF dual-hosted git repository. kumfo pushed a commit to branch feat/cdn/s3 in repository https://gitbox.apache.org/repos/asf/incubator-answer-plugins.git
commit 5ad1687325b471c33b01d402f921f8c12ef8d737 Author: kumfo <[email protected]> AuthorDate: Mon Jul 8 18:10:18 2024 +0800 feat(cdn):Feat #129, Closes #129. Support CDN with AWS S3 --- cdn-s3/README.md | 19 +++ cdn-s3/go.mod | 47 ++++++ cdn-s3/i18n/en_US.yaml | 70 ++++++++ cdn-s3/i18n/translation.go | 32 ++++ cdn-s3/i18n/zh_CN.yaml | 70 ++++++++ cdn-s3/s3.go | 402 +++++++++++++++++++++++++++++++++++++++++++++ cdn-s3/s3_client.go | 67 ++++++++ 7 files changed, 707 insertions(+) diff --git a/cdn-s3/README.md b/cdn-s3/README.md new file mode 100644 index 0000000..7dc3f19 --- /dev/null +++ b/cdn-s3/README.md @@ -0,0 +1,19 @@ +# CDN With S3 Storage (preview) +> This plugin can be used to store static files to AWS S3. + +## How to use + +### Build +```bash +./answer build --with github.com/answerdev/plugins/cdn-s3 +``` + +### Configuration +- `Endpoint` - Endpoint of the AWS S3 storage +- `Bucket Name` - Your bucket name +- `Object Key Prefix` - Prefix of the object key like 'static/' that ending with '/' +- `Access Key Id` - AccessKeyId of the S3 +- `Access Key Secret` - AccessKeySecret of the S3 +- `Access Token` - AccessToken of the S3 +- `Visit Url Prefix` - Prefix of access address for the static file, ending with '/' such as https://static.example.com/xxx/ +- `Max File Size` - Max file size in MB, default is 10MB \ No newline at end of file diff --git a/cdn-s3/go.mod b/cdn-s3/go.mod new file mode 100644 index 0000000..ff58617 --- /dev/null +++ b/cdn-s3/go.mod @@ -0,0 +1,47 @@ +module github.com/apache/incubator-answer-plugins/cdn-s3 + +go 1.19 + +require ( + github.com/apache/incubator-answer v1.3.6 + github.com/aws/aws-sdk-go v1.44.314 +) + +require ( + github.com/LinkinStars/go-i18n/v2 v2.2.2 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.9.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/wire v0.5.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.21 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/segmentfault/pacman v1.0.5-0.20230822083413-c0075a2d401f // indirect + github.com/segmentfault/pacman/contrib/i18n v0.0.0-20230516093754-b76aef1c1150 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/cdn-s3/i18n/en_US.yaml b/cdn-s3/i18n/en_US.yaml new file mode 100644 index 0000000..17d5c0b --- /dev/null +++ b/cdn-s3/i18n/en_US.yaml @@ -0,0 +1,70 @@ +plugin: + s3_cdn: + backend: + info: + name: + other: S3 storage CDN + description: + other: Upload files to S3 storage + config: + endpoint: + title: + other: Endpoint + description: + other: Endpoint of S3 storage + bucket_name: + title: + other: Bucket name + description: + other: Bucket name of S3 storage + object_key_prefix: + title: + other: Object Key prefix + description: + other: prefix of the object key like 'answer/data/' that ending with '/' + access_key_id: + title: + other: AccessKeyID + description: + other: AccessKeyID of the S3 storage + access_key_secret: + title: + other: AccessKeySecret + description: + other: AccessKeySecret of S3 storage + access_token: + title: + other: AccessToken + description: + other: AccessToken of S3 storage + visit_url_prefix: + title: + other: Access URL prefix + description: + other: prefix of the final access address of the static file, ending with '/' https://static.example.com/xxx/ + max_file_size: + title: + other: Maximum file size(MB) + description: + other: Limit the maximum size of uploaded files, in MB, default is 10MB + region: + title: + other: Region + description: + other: Region of S3 storage + disable_ssl: + title: + other: Disable SSL + description: + other: We recommend that you use SSL to access S3 storage. If you want to disable SSL, please check this option. + err: + mis_storage_config: + other: Wrong storage configuration causes upload failure. + file_not_found: + other: File not found. + unsupported_file_type: + other: Unsupported file type. + over_file_size_limit: + other: File size limit exceeded. + upload_file_failed: + other: Failed to upload a file. \ No newline at end of file diff --git a/cdn-s3/i18n/translation.go b/cdn-s3/i18n/translation.go new file mode 100644 index 0000000..8b71799 --- /dev/null +++ b/cdn-s3/i18n/translation.go @@ -0,0 +1,32 @@ +package i18n + +const ( + InfoName = "plugin.s3_cdn.backend.info.name" + InfoDescription = "plugin.s3_cdn.backend.info.description" + + ConfigEndpointTitle = "plugin.s3_cdn.backend.config.endpoint.title" + ConfigEndpointDescription = "plugin.s3_cdn.backend.config.endpoint.description" + ConfigBucketNameTitle = "plugin.s3_cdn.backend.config.bucket_name.title" + ConfigBucketNameDescription = "plugin.s3_cdn.backend.config.bucket_name.description" + ConfigObjectKeyPrefixTitle = "plugin.s3_cdn.backend.config.object_key_prefix.title" + ConfigObjectKeyPrefixDescription = "plugin.s3_cdn.backend.config.object_key_prefix.description" + ConfigAccessKeyIdTitle = "plugin.s3_cdn.backend.config.access_key_id.title" + ConfigAccessKeyIdDescription = "plugin.s3_cdn.backend.config.access_key_id.description" + ConfigAccessKeySecretTitle = "plugin.s3_cdn.backend.config.access_key_secret.title" + ConfigAccessKeySecretDescription = "plugin.s3_cdn.backend.config.access_key_secret.description" + ConfigAccessTokenTitle = "plugin.s3_cdn.backend.config.access_token.title" + ConfigAccessTokenDescription = "plugin.s3_cdn.backend.config.access_token.description" + ConfigVisitUrlPrefixTitle = "plugin.s3_cdn.backend.config.visit_url_prefix.title" + ConfigVisitUrlPrefixDescription = "plugin.s3_cdn.backend.config.visit_url_prefix.description" + ConfigMaxFileSizeTitle = "plugin.s3_cdn.backend.config.max_file_size.title" + ConfigMaxFileSizeDescription = "plugin.s3_cdn.backend.config.max_file_size.description" + ConfigRegionTitle = "plugin.s3_cdn.backend.config.region.title" + ConfigRegionDescription = "plugin.s3_cdn.backend.config.region.description" + ConfigDisableSSLTitle = "plugin.s3_cdn.backend.config.disable_ssl.title" + ConfigDisableSSLDescription = "plugin.s3_cdn.backend.config.disable_ssl.description" + + ErrFileNotFound = "plugin.s3_cdn.backend.err.file_not_found" + ErrUnsupportedFileType = "plugin.s3_cdn.backend.err.unsupported_file_type" + ErrOverFileSizeLimit = "plugin.s3_cdn.backend.err.over_file_size_limit" + ErrUploadFileFailed = "plugin.s3_cdn.backend.err.upload_file_failed" +) diff --git a/cdn-s3/i18n/zh_CN.yaml b/cdn-s3/i18n/zh_CN.yaml new file mode 100644 index 0000000..737e08b --- /dev/null +++ b/cdn-s3/i18n/zh_CN.yaml @@ -0,0 +1,70 @@ +plugin: + s3_cdn: + backend: + info: + name: + other: S3 存储CDN + description: + other: 上传文件到S3存储 + config: + endpoint: + title: + other: Endpoint + description: + other: S3存储的Endpoint + bucket_name: + title: + other: Bucket名称 + description: + other: S3存储的Bucket名称 + object_key_prefix: + title: + other: 对象Key前缀 + description: + other: 对象键的前缀,如'answer/data/',以'/'结尾 + access_key_id: + title: + other: AccessKeyID + description: + other: S3存储的AccessKeyID + access_key_secret: + title: + other: AccessKeySecret + description: + other: S3存储的AccessKeySecret + access_token: + title: + other: AccessToken + description: + other: 访问 S3 所需的 AccessToken + visit_url_prefix: + title: + other: 访问URL前缀 + description: + other: 上传静态文件CDN最终访问地址的前缀,以 '/' 结尾 https://static.example.com/xxx/ + max_file_size: + title: + other: 文件最大大小(MB) + description: + other: 限制上传文件的最大大小,单位MB,默认为10MB + region: + title: + other: 区域(Region) + description: + other: S3存储区域 + disable_ssl: + title: + other: 禁用SSL + description: + other: 我们建议您使用SSL访问S3存储。如果您想禁用SSL,请选中此选项。 + err: + mis_storage_config: + other: 错误的存储配置导致上传失败 + file_not_found: + other: 文件未找到 + unsupported_file_type: + other: 不支持的文件类型 + over_file_size_limit: + other: 超过文件大小限制 + upload_file_failed: + other: 上传文件失败 \ No newline at end of file diff --git a/cdn-s3/s3.go b/cdn-s3/s3.go new file mode 100644 index 0000000..656eb48 --- /dev/null +++ b/cdn-s3/s3.go @@ -0,0 +1,402 @@ +package s3 + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "github.com/apache/incubator-answer-plugins/cdn-s3/i18n" + "github.com/apache/incubator-answer/ui" + "github.com/segmentfault/pacman/log" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/apache/incubator-answer/plugin" +) + +var staticPath = os.Getenv("ANSWER_STATIC_PATH") + +const ( + // 10MB + defaultMaxFileSize int64 = 10 * 1024 * 1024 +) + +type CDN struct { + Config *CDNConfig + Client *Client +} + +type CDNConfig struct { + Endpoint string `json:"endpoint"` + BucketName string `json:"bucket_name"` + ObjectKeyPrefix string `json:"object_key_prefix"` + AccessKeyID string `json:"access_key_id"` + AccessKeySecret string `json:"access_key_secret"` + AccessToken string `json:"access_token"` + VisitUrlPrefix string `json:"visit_url_prefix"` + MaxFileSize string `json:"max_file_size"` + Region string `json:"region"` + DisableSSL bool `json:"disable_ssl"` +} + +func init() { + plugin.Register(&CDN{ + Config: &CDNConfig{}, + }) +} + +func (c *CDN) Info() plugin.Info { + return plugin.Info{ + Name: plugin.MakeTranslator(i18n.InfoName), + SlugName: "s3_cdn", + Description: plugin.MakeTranslator(i18n.InfoDescription), + Author: "answerdev", + Version: "0.0.1", + Link: "https://github.com/answerdev/plugins/tree/main/cdn-s3", + } +} + +// GetStaticPrefix get static prefix +func (c *CDN) GetStaticPrefix() string { + return c.Config.VisitUrlPrefix + c.Config.ObjectKeyPrefix +} + +// scanFiles scan all the static files in the build directory +func (c *CDN) scanFiles() { + if staticPath == "" { + c.scanEmbedFiles("build") + log.Info("complete scan embed files") + return + } + c.scanStaticPathFiles(staticPath) + log.Info("complete scan static path files") +} + +// scanStaticPathFiles scan static path files +func (c *CDN) scanStaticPathFiles(fileName string) { + // scan static path files + entry, err := os.ReadDir(fileName) + if err != nil { + log.Error("read static dir failed: %v", err) + return + } + for _, info := range entry { + if info.IsDir() { + c.scanStaticPathFiles(filepath.Join(fileName, info.Name())) + continue + } + + filePath := filepath.Join(fileName, info.Name()) + fi, _ := info.Info() + size := fi.Size() + file, err := os.Open(filePath) + if err != nil { + log.Error("open file failed: %v", err) + continue + } + + suffix := staticPath[:1] + if suffix != "/" { + suffix = "" + } + filePath = strings.TrimPrefix(filePath, staticPath+suffix) + + // rebuild custom io.Reader + ns := strings.Split(info.Name(), ".") + if info.Name() == "asset-manifest.json" { + c.Upload(filePath, c.rebuildReader(file, map[string]string{ + "\"/static": "", + }), size) + continue + } + if ns[0] == "main" { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == ".js" || ext == ".map" { + c.Upload(filePath, c.rebuildReader(file, map[string]string{ + "\"static": "", + "=\"/\",": "=\"\",", + }), size) + continue + } + + if ext == ".css" { + c.Upload(filePath, c.rebuildReader(file, map[string]string{ + "url(/static": "url(../../static", + }), size) + continue + } + } + + c.Upload(filePath, file, size) + } +} + +func (c *CDN) scanEmbedFiles(fileName string) { + entry, err := ui.Build.ReadDir(fileName) + if err != nil { + log.Error("read static dir failed: %v", err) + return + } + for _, info := range entry { + if info.IsDir() { + c.scanEmbedFiles(filepath.Join(fileName, info.Name())) + continue + } + + filePath := filepath.Join(fileName, info.Name()) + fi, _ := info.Info() + size := fi.Size() + file, err := ui.Build.Open(filePath) + defer file.Close() + if err != nil { + log.Error("open file failed: %v", err) + continue + } + + filePath = strings.TrimPrefix(filePath, "build/") + + // rebuild custom io.Reader + ns := strings.Split(info.Name(), ".") + if info.Name() == "asset-manifest.json" { + c.Upload(filePath, c.rebuildReader(file, map[string]string{ + "\"/static": "", + }), size) + continue + } + + if ns[0] == "main" { + ext := strings.ToLower(filepath.Ext(filePath)) + if ext == ".js" || ext == ".map" { + c.Upload(filePath, c.rebuildReader(file, map[string]string{ + "\"static": "", + "=\"/\",": "=\"\",", + }), size) + continue + } + + if ext == ".css" { + c.Upload(filePath, c.rebuildReader(file, map[string]string{ + "url(/static": "url(../../static", + }), size) + continue + } + } + + c.Upload(filePath, c.rebuildReader(file, nil), size) + } +} + +func (c *CDN) rebuildReader(file io.Reader, replaceMap map[string]string) io.ReadSeeker { + var ( + bufr = make([]byte, 0) + res string + ) + + for { + buf := make([]byte, 1024) + n, err := file.Read(buf) + if err != nil { + break + } + bufr = append(bufr, buf[:n]...) + } + + res = string(bufr) + + if replaceMap != nil { + for oldStr, newStr := range replaceMap { + if oldStr != "" { + if newStr == "" { + newStr = "\"" + c.GetStaticPrefix() + "/static" + } + res = strings.ReplaceAll(res, oldStr, newStr) + } + } + } + + return strings.NewReader(res) +} +func (c *CDN) Upload(filePath string, file io.ReadSeeker, size int64) { + + if !c.CheckFileType(filePath) { + log.Error(plugin.MakeTranslator(i18n.ErrUnsupportedFileType), filePath) + return + } + + if size > c.maxFileSizeLimit() { + log.Error(plugin.MakeTranslator(i18n.ErrOverFileSizeLimit)) + return + } + + objectKey := c.createObjectKey(filePath) + + err := c.Client.PutObject(objectKey, strings.ToLower(filepath.Ext(filePath)), file) + if err != nil { + log.Error(plugin.MakeTranslator(i18n.ErrUploadFileFailed), err) + return + } + return +} + +func (c *CDN) createObjectKey(filePath string) string { + return c.Config.ObjectKeyPrefix + filePath +} + +func (c *CDN) randomObjectKey() string { + bytes := make([]byte, 4) + _, _ = rand.Read(bytes) + return fmt.Sprintf("%d", time.Now().UnixNano()) + hex.EncodeToString(bytes) +} + +func (c *CDN) CheckFileType(filePath string) bool { + ext := strings.ToLower(filepath.Ext(filePath)) + if _, ok := plugin.DefaultCDNFileType[ext]; ok { + return true + } + return false +} + +func (c *CDN) maxFileSizeLimit() int64 { + if len(c.Config.MaxFileSize) == 0 { + return defaultMaxFileSize + } + limit, _ := strconv.Atoi(c.Config.MaxFileSize) + if limit <= 0 { + return defaultMaxFileSize + } + return int64(limit) * 1024 * 1024 +} + +func (c *CDN) ConfigFields() []plugin.ConfigField { + return []plugin.ConfigField{ + { + Name: "endpoint", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigEndpointTitle), + Description: plugin.MakeTranslator(i18n.ConfigEndpointDescription), + Required: true, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.Endpoint, + }, + { + Name: "bucket_name", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigBucketNameTitle), + Description: plugin.MakeTranslator(i18n.ConfigBucketNameDescription), + Required: true, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.BucketName, + }, + { + Name: "object_key_prefix", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigObjectKeyPrefixTitle), + Description: plugin.MakeTranslator(i18n.ConfigObjectKeyPrefixDescription), + Required: false, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.ObjectKeyPrefix, + }, + { + Name: "access_key_id", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigAccessKeyIdTitle), + Description: plugin.MakeTranslator(i18n.ConfigAccessKeyIdDescription), + Required: false, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.AccessKeyID, + }, + { + Name: "access_key_secret", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigAccessKeySecretTitle), + Description: plugin.MakeTranslator(i18n.ConfigAccessKeySecretDescription), + Required: false, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.AccessKeySecret, + }, + { + Name: "access_token", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigAccessTokenTitle), + Description: plugin.MakeTranslator(i18n.ConfigAccessTokenDescription), + Required: false, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.AccessToken, + }, + { + Name: "visit_url_prefix", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigVisitUrlPrefixTitle), + Description: plugin.MakeTranslator(i18n.ConfigVisitUrlPrefixDescription), + Required: true, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.VisitUrlPrefix, + }, + { + Name: "max_file_size", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigMaxFileSizeTitle), + Description: plugin.MakeTranslator(i18n.ConfigMaxFileSizeDescription), + Required: false, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeNumber, + }, + Value: c.Config.MaxFileSize, + }, + { + Name: "region", + Type: plugin.ConfigTypeInput, + Title: plugin.MakeTranslator(i18n.ConfigRegionTitle), + Description: plugin.MakeTranslator(i18n.ConfigRegionDescription), + Required: true, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.Region, + }, + { + Name: "disable_ssl", + Type: plugin.ConfigTypeSwitch, + Title: plugin.MakeTranslator(i18n.ConfigDisableSSLTitle), + Value: c.Config.DisableSSL, + UIOptions: plugin.ConfigFieldUIOptions{ + Label: plugin.MakeTranslator(i18n.ConfigDisableSSLDescription), + }, + }, + } +} + +func (c *CDN) ConfigReceiver(config []byte) error { + cfg := &CDNConfig{} + _ = json.Unmarshal(config, cfg) + c.Config = cfg + c.Client = NewS3Client( + c.Config.AccessKeyID, + c.Config.AccessKeySecret, + c.Config.AccessToken, + c.Config.Endpoint, + c.Config.Region, + c.Config.BucketName, + c.Config.DisableSSL, + ) + go c.scanFiles() + return nil +} diff --git a/cdn-s3/s3_client.go b/cdn-s3/s3_client.go new file mode 100644 index 0000000..aa29095 --- /dev/null +++ b/cdn-s3/s3_client.go @@ -0,0 +1,67 @@ +package s3 + +import ( + "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "io" + "strings" +) + +type Client struct { + s3Config *aws.Config + bucket string +} + +func NewS3Client(id, secret, token, endpoint, region, bucket string, disableSSL bool) *Client { + s3Client := &Client{ + s3Config: &aws.Config{ + Credentials: credentials.NewStaticCredentials(id, secret, token), + Endpoint: aws.String(endpoint), + Region: aws.String(region), + DisableSSL: aws.Bool(disableSSL), + S3ForcePathStyle: aws.Bool(true), + }, + bucket: bucket, + } + return s3Client +} + +func (s *Client) PutObject(key, ext string, file io.ReadSeeker) (err error) { + newSession, err := session.NewSession(s.s3Config) + if err != nil { + return fmt.Errorf("failed to create session, %s", err.Error()) + } + + extType := strings.TrimPrefix(ext, ".") + contentType := "" + switch extType { + case "jpg", "jpeg", "png": + contentType = "image/" + extType + case "svg": + contentType = "image/svg+xml" + case "js": + contentType = "application/javascript" + case "css": + contentType = "text/css" + case "map": + contentType = "application/json" + case "woff": + contentType = "application/font-woff" + case "woff2": + contentType = "application/font-woff2" + } + + _, err = s3.New(newSession).PutObject(&s3.PutObjectInput{ + Body: file, + Bucket: aws.String(s.bucket), + Key: aws.String(key), + ContentType: aws.String(contentType), + }) + if err != nil { + return fmt.Errorf("failed to put object, %s", err.Error()) + } + return nil +}
