| /* |
| * 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 s3 |
| |
| import ( |
| "crypto/rand" |
| "embed" |
| "encoding/hex" |
| "encoding/json" |
| "fmt" |
| "io" |
| "io/fs" |
| "net/http" |
| "os" |
| "path/filepath" |
| "strconv" |
| "strings" |
| "time" |
| |
| "github.com/apache/answer-plugins/cdn-s3/i18n" |
| "github.com/apache/answer-plugins/util" |
| "github.com/apache/answer/plugin" |
| "github.com/apache/answer/ui" |
| "github.com/segmentfault/pacman/log" |
| ) |
| |
| var ( |
| staticPath = os.Getenv("ANSWER_STATIC_PATH") |
| enable = false |
| ) |
| |
| //go:embed info.yaml |
| var Info embed.FS |
| |
| 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 { |
| info := util.Info{} |
| info.GetInfo(Info) |
| return plugin.Info{ |
| Name: plugin.MakeTranslator(i18n.InfoName), |
| SlugName: info.SlugName, |
| Description: plugin.MakeTranslator(i18n.InfoDescription), |
| Author: info.Author, |
| Version: info.Version, |
| Link: info.Link, |
| } |
| } |
| |
| // GetStaticPrefix get static prefix |
| func (c *CDN) GetStaticPrefix() string { |
| if !enable { |
| return "" |
| } |
| return c.Config.VisitUrlPrefix + c.Config.ObjectKeyPrefix |
| } |
| |
| // scanFiles scan all the static files in the build directory |
| func (c *CDN) scanFiles() { |
| if staticPath == "" { |
| err := c.scanEmbedFiles("build") |
| if err != nil { |
| enable = false |
| log.Error("failed: scan embed files: ", err) |
| return |
| } |
| log.Info("complete: scan embed files") |
| enable = true |
| return |
| } |
| |
| err := c.scanStaticPathFiles(staticPath) |
| if err != nil { |
| enable = false |
| log.Error("fialed: scan static path files: ", err) |
| return |
| } |
| enable = true |
| log.Info("complete: scan static path files") |
| } |
| |
| // scanStaticPathFiles scan static path files |
| func (c *CDN) scanStaticPathFiles(fileName string) (err error) { |
| if len(fileName) == 0 { |
| return |
| } |
| |
| // 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() { |
| err = c.scanStaticPathFiles(filepath.Join(fileName, info.Name())) |
| if err != nil { |
| return |
| } |
| } |
| |
| var file *os.File |
| 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) |
| return |
| } |
| |
| 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" { |
| err = c.Upload(filePath, c.rebuildReader(file, map[string]string{ |
| "\"/static": "", |
| }), size) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| if ns[0] == "main" { |
| ext := strings.ToLower(filepath.Ext(filePath)) |
| if ext == ".js" || ext == ".map" { |
| err = c.Upload(filePath, c.rebuildReader(file, map[string]string{ |
| "\"static": "", |
| "=\"/\",": "=\"\",", |
| }), size) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| |
| if ext == ".css" { |
| err = c.Upload(filePath, c.rebuildReader(file, map[string]string{ |
| "url(/static": "url(../../static", |
| }), size) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| } |
| |
| err = c.Upload(filePath, file, size) |
| if err != nil { |
| return |
| } |
| } |
| return |
| } |
| |
| func (c *CDN) scanEmbedFiles(fileName string) (err error) { |
| 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() { |
| err = c.scanEmbedFiles(filepath.Join(fileName, info.Name())) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| |
| var file fs.File |
| 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) |
| return |
| } |
| |
| filePath = strings.TrimPrefix(filePath, "build/") |
| |
| // rebuild custom io.Reader |
| ns := strings.Split(info.Name(), ".") |
| if info.Name() == "asset-manifest.json" { |
| err = c.Upload(filePath, c.rebuildReader(file, map[string]string{ |
| "\"/static": "", |
| }), size) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| |
| if ns[0] == "main" { |
| ext := strings.ToLower(filepath.Ext(filePath)) |
| if ext == ".js" || ext == ".map" { |
| err = c.Upload(filePath, c.rebuildReader(file, map[string]string{ |
| "\"static": "", |
| "=\"/\",": "=\"\",", |
| }), size) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| |
| if ext == ".css" { |
| err = c.Upload(filePath, c.rebuildReader(file, map[string]string{ |
| "url(/static": "url(../../static", |
| }), size) |
| if err != nil { |
| return |
| } |
| continue |
| } |
| } |
| |
| err = c.Upload(filePath, c.rebuildReader(file, nil), size) |
| if err != nil { |
| return |
| } |
| } |
| return |
| } |
| |
| 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 == "" { |
| prefix := c.Config.VisitUrlPrefix + c.Config.ObjectKeyPrefix |
| if prefix[len(prefix)-1:] == "/" { |
| prefix = strings.TrimSuffix(prefix, "/") |
| } |
| newStr = "\"" + prefix + "/static" |
| } |
| res = strings.ReplaceAll(res, oldStr, newStr) |
| } |
| } |
| } |
| |
| return strings.NewReader(res) |
| } |
| func (c *CDN) Upload(filePath string, file io.ReadSeeker, size int64) (err error) { |
| |
| 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 c.checkCDNAvailable(objectKey) |
| } |
| |
| func (c *CDN) checkCDNAvailable(objectKey string) error { |
| url := c.Config.VisitUrlPrefix + objectKey |
| req, err := http.NewRequest("GET", url, nil) |
| if err != nil { |
| return err |
| } |
| |
| client := &http.Client{} |
| response, err := client.Do(req) |
| if err != nil { |
| return err |
| } |
| defer response.Body.Close() |
| |
| if response.StatusCode != http.StatusOK { |
| log.Error("check error:", url) |
| return fmt.Errorf("failed to get object, %s", response.Status) |
| } |
| return nil |
| } |
| |
| 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 |
| } |