This is an automated email from the ASF dual-hosted git repository. kumfo pushed a commit to branch feat/cdn/oss in repository https://gitbox.apache.org/repos/asf/incubator-answer-plugins.git
commit fdbd186f2355995ce622b1eb14c973552aff3688 Author: kumfo <[email protected]> AuthorDate: Mon Jul 8 11:23:13 2024 +0800 feat(cdn):Feat #128, Closes #128. Support CDN with Aliyun OSS --- cdn-aliyunoss/README.md | 18 ++ cdn-aliyunoss/aliyunoss.go | 383 ++++++++++++++++++++++++++++++++++++++ cdn-aliyunoss/go.mod | 47 +++++ cdn-aliyunoss/i18n/en_US.yaml | 72 +++++++ cdn-aliyunoss/i18n/translation.go | 45 +++++ cdn-aliyunoss/i18n/zh_CN.yaml | 72 +++++++ 6 files changed, 637 insertions(+) diff --git a/cdn-aliyunoss/README.md b/cdn-aliyunoss/README.md new file mode 100644 index 0000000..ceabbfe --- /dev/null +++ b/cdn-aliyunoss/README.md @@ -0,0 +1,18 @@ +# CDN With Aliyun OSS Storage (preview) +> This plugin can be used to store static files to Aliyun OSS. + +## How to use + +### Build +```bash +./answer build --with github.com/apache/incubator-answer-plugins/cdn-aliyunoss +``` + +### Configuration +- `Endpoint` - Endpoint of AliCloud OSS storage, such as oss-cn-hangzhou.aliyuncs.com +- `Bucket Name` - Your bucket name +- `Object Key Prefix` - Prefix of the object key like 'static/' that ending with '/' +- `Access Key Id` - AccessKeyID of the AliCloud OSS storage +- `Access Key Secret` - AccessKeySecret of the AliCloud OSS storage +- `Visit Url Prefix` - Prefix of access address for the CDN 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-aliyunoss/aliyunoss.go b/cdn-aliyunoss/aliyunoss.go new file mode 100644 index 0000000..9c741b8 --- /dev/null +++ b/cdn-aliyunoss/aliyunoss.go @@ -0,0 +1,383 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aliyunoss + +import ( + "encoding/json" + "github.com/aliyun/aliyun-oss-go-sdk/oss" + "github.com/apache/incubator-answer-plugins/cdn-aliyunoss/i18n" + "github.com/apache/incubator-answer/plugin" + "github.com/apache/incubator-answer/ui" + "github.com/segmentfault/pacman/log" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +var staticPath = os.Getenv("ANSWER_STATIC_PATH") + +const ( + // 10MB + defaultMaxFileSize int64 = 10 * 1024 * 1024 +) + +type CDN struct { + Config *CDNConfig +} + +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"` + VisitUrlPrefix string `json:"visit_url_prefix"` + MaxFileSize string `json:"max_file_size"` +} + +type CustomFile struct { + file io.Reader + cdnPrefix string + old string +} + +func (f *CustomFile) Read(p []byte) (n int, err error) { + c := make([]byte, len(p)) + n, err = f.file.Read(c) + if f.old != "" { + c = []byte(strings.ReplaceAll(string(c), f.old, "\""+f.cdnPrefix+"/static")) + } + + n = copy(p, c) + return +} + +func (f *CustomFile) Close() error { return nil } + +func init() { + plugin.Register(&CDN{ + Config: &CDNConfig{}, + }) +} + +func (c *CDN) Info() plugin.Info { + return plugin.Info{ + Name: plugin.MakeTranslator(i18n.InfoName), + SlugName: "aliyunoss_cdn", + Description: plugin.MakeTranslator(i18n.InfoDescription), + Author: "answerdev", + Version: "1.0.0", + Link: "https://github.com/apache/incubator-answer-plugins/tree/main/cdn-aliyunoss", + } +} + +// 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 + } + + 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 + } + + c.Upload(filePath, file, size) + } +} + +func (c *CDN) rebuildReader(file io.Reader, replaceMap map[string]string) io.Reader { + 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) + + 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.Reader, size int64) { + client, err := oss.New(c.Config.Endpoint, c.Config.AccessKeyID, c.Config.AccessKeySecret) + if err != nil { + log.Error(plugin.MakeTranslator(i18n.ErrMisStorageConfig), err) + return + } + bucket, err := client.Bucket(c.Config.BucketName) + if err != nil { + log.Error(plugin.MakeTranslator(i18n.ErrMisStorageConfig), err) + return + } + + 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) + request := &oss.PutObjectRequest{ + ObjectKey: objectKey, + Reader: file, + } + respBody, err := bucket.DoPutObject(request, nil) + if err != nil { + log.Error(plugin.MakeTranslator(i18n.ErrUploadFileFailed), err) + return + } + defer respBody.Close() +} + +func (c *CDN) createObjectKey(filePath string) string { + return c.Config.ObjectKeyPrefix + filePath +} + +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: true, + 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: true, + UIOptions: plugin.ConfigFieldUIOptions{ + InputType: plugin.InputTypeText, + }, + Value: c.Config.AccessKeySecret, + }, + { + 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, + }, + } +} + +func (c *CDN) ConfigReceiver(config []byte) error { + cfg := &CDNConfig{} + _ = json.Unmarshal(config, cfg) + c.Config = cfg + + go c.scanFiles() + return nil +} diff --git a/cdn-aliyunoss/go.mod b/cdn-aliyunoss/go.mod new file mode 100644 index 0000000..140928c --- /dev/null +++ b/cdn-aliyunoss/go.mod @@ -0,0 +1,47 @@ +module github.com/apache/incubator-answer-plugins/cdn-aliyunoss + +go 1.19 + +require ( + github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible + github.com/apache/incubator-answer v1.3.6 +) + +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/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 + golang.org/x/time v0.5.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-aliyunoss/i18n/en_US.yaml b/cdn-aliyunoss/i18n/en_US.yaml new file mode 100644 index 0000000..2aa7e27 --- /dev/null +++ b/cdn-aliyunoss/i18n/en_US.yaml @@ -0,0 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +plugin: + aliyunoss_storage: + backend: + info: + name: + other: Aliyun CDN OSS storage + description: + other: Upload files to AliCloud CDN OSS storage + config: + endpoint: + title: + other: Endpoint + description: + other: Endpoint of AliCloud OSS storage + bucket_name: + title: + other: Bucket name + description: + other: Bucket name of AliCloud OSS 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 AliCloud OSS storage + access_key_secret: + title: + other: AccessKeySecret + description: + other: AccessKeySecret of AliCloud OSS storage + visit_url_prefix: + title: + other: Access URL prefix + description: + other: prefix of the final access address of the CDN, ending with '/' https://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 + 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-aliyunoss/i18n/translation.go b/cdn-aliyunoss/i18n/translation.go new file mode 100644 index 0000000..773fe17 --- /dev/null +++ b/cdn-aliyunoss/i18n/translation.go @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package i18n + +const ( + InfoName = "plugin.aliyunoss_cdn.backend.info.name" + InfoDescription = "plugin.aliyunoss_cdn.backend.info.description" + + ConfigEndpointTitle = "plugin.aliyunoss_cdn.backend.config.endpoint.title" + ConfigEndpointDescription = "plugin.aliyunoss_cdn.backend.config.endpoint.description" + ConfigBucketNameTitle = "plugin.aliyunoss_cdn.backend.config.bucket_name.title" + ConfigBucketNameDescription = "plugin.aliyunoss_cdn.backend.config.bucket_name.description" + ConfigObjectKeyPrefixTitle = "plugin.aliyunoss_cdn.backend.config.object_key_prefix.title" + ConfigObjectKeyPrefixDescription = "plugin.aliyunoss_cdn.backend.config.object_key_prefix.description" + ConfigAccessKeyIdTitle = "plugin.aliyunoss_cdn.backend.config.access_key_id.title" + ConfigAccessKeyIdDescription = "plugin.aliyunoss_cdn.backend.config.access_key_id.description" + ConfigAccessKeySecretTitle = "plugin.aliyunoss_cdn.backend.config.access_key_secret.title" + ConfigAccessKeySecretDescription = "plugin.aliyunoss_cdn.backend.config.access_key_secret.description" + ConfigVisitUrlPrefixTitle = "plugin.aliyunoss_cdn.backend.config.visit_url_prefix.title" + ConfigVisitUrlPrefixDescription = "plugin.aliyunoss_cdn.backend.config.visit_url_prefix.description" + ConfigMaxFileSizeTitle = "plugin.aliyunoss_cdn.backend.config.max_file_size.title" + ConfigMaxFileSizeDescription = "plugin.aliyunoss_cdn.backend.config.max_file_size.description" + + ErrMisStorageConfig = "plugin.aliyunoss_cdn.backend.err.mis_storage_config" + ErrUnsupportedFileType = "plugin.aliyunoss_cdn.backend.err.unsupported_file_type" + ErrOverFileSizeLimit = "plugin.aliyunoss_cdn.backend.err.over_file_size_limit" + ErrUploadFileFailed = "plugin.aliyunoss_cdn.backend.err.upload_file_failed" +) diff --git a/cdn-aliyunoss/i18n/zh_CN.yaml b/cdn-aliyunoss/i18n/zh_CN.yaml new file mode 100644 index 0000000..7044a64 --- /dev/null +++ b/cdn-aliyunoss/i18n/zh_CN.yaml @@ -0,0 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +plugin: + aliyunoss_storage: + backend: + info: + name: + other: 阿里云CDN OSS + description: + other: 上传文件到阿里云CDN OSS + config: + endpoint: + title: + other: Endpoint + description: + other: 阿里云OSS存储的Endpoint + bucket_name: + title: + other: Bucket名称 + description: + other: 阿里云OSS存储的Bucket名称 + object_key_prefix: + title: + other: 对象Key前缀 + description: + other: 对象键的前缀,如'answer/data/',以'/'结尾 + access_key_id: + title: + other: AccessKeyID + description: + other: 阿里云OSS存储的AccessKeyID + access_key_secret: + title: + other: AccessKeySecret + description: + other: 阿里云OSS存储的AccessKeySecret + visit_url_prefix: + title: + other: 访问URL前缀 + description: + other: CDN最终访问地址的前缀,以 '/' 结尾 https://example.com/xxx/ + max_file_size: + title: + other: 最大文件大小(MB) + description: + other: 限制上传文件的最大大小,单位为MB,默认为 10MB + 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
