From e706620f122a90a5d2a823acddefebb2da8fecd2 Mon Sep 17 00:00:00 2001 From: withchao <48119764+withchao@users.noreply.github.com> Date: Tue, 8 Aug 2023 16:40:07 +0800 Subject: [PATCH] feat: server thumbnail (#818) * feat: add get_group_member_user_id api * feat: add SendBusinessNotification api * feat: add GetFriendIDs api * update pkg * feat: cos oss thumbnail * feat: cos video snapshot * feat: oss video snapshot * feat: minio video snapshot * feat: minio video snapshot * feat: minio * feat: minio * feat: minio * feat: s3 AccessURL * feat: s3 AccessURL * fix: Minio AccessURL * fix: Minio AccessURL * fix: optimize thumbnails * fix: optimize thumbnails * fix: cos option * fix: cos option * fix: cos option * docs: config * docs: config * minio: preview image cache * minio: preview image cache * minio: preview image cache * go mod tidy * cicd: robot automated Change Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --------- Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: withchao --- config/config.yaml | 82 +++++------ go.mod | 2 +- internal/api/third.go | 9 +- internal/rpc/third/s3.go | 19 ++- internal/rpc/third/third.go | 1 + pkg/common/config/config.go | 1 + pkg/common/db/controller/s3.go | 15 +- pkg/common/db/controller/user.go | 3 +- pkg/common/db/s3/cont/controller.go | 4 + pkg/common/db/s3/cos/cos.go | 56 +++++++- pkg/common/db/s3/minio/image.go | 106 ++++++++++++++ pkg/common/db/s3/minio/minio.go | 207 ++++++++++++++++++++++++++-- pkg/common/db/s3/minio/struct.go | 8 ++ pkg/common/db/s3/oss/oss.go | 49 +++++-- pkg/common/db/s3/oss/sign.go | 91 +----------- pkg/common/db/s3/oss/sort.go | 61 -------- pkg/common/db/s3/s3.go | 7 + pkg/common/db/unrelation/user.go | 4 +- 18 files changed, 500 insertions(+), 225 deletions(-) create mode 100644 pkg/common/db/s3/minio/image.go create mode 100644 pkg/common/db/s3/minio/struct.go delete mode 100644 pkg/common/db/s3/oss/sort.go diff --git a/config/config.yaml b/config/config.yaml index 348aa2e93..035577b3d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -24,10 +24,10 @@ # Zookeeper username # Zookeeper password zookeeper: - schema: openim - address: [ 127.0.0.1:2181 ] - username: - password: + schema: openim + address: [ 127.0.0.1:2181 ] + username: + password: ###################### Mysql ###################### # MySQL configuration @@ -42,12 +42,12 @@ mysql: address: [ 127.0.0.1:13306 ] username: root password: openIM123 - database: openIM_v3 - maxOpenConn: 1000 - maxIdleConn: 100 - maxLifeTime: 60 - logLevel: 4 - slowThreshold: 500 + database: openIM_v3 + maxOpenConn: 1000 + maxIdleConn: 100 + maxLifeTime: 60 + logLevel: 4 + slowThreshold: 500 ###################### Mongo ###################### # MongoDB configuration @@ -62,7 +62,7 @@ mongo: database: openIM_v3 username: root password: openIM123 - maxPoolSize: 100 + maxPoolSize: 100 ###################### Redis ###################### # Redis configuration @@ -70,7 +70,7 @@ mongo: # Username is required only for Redis version 6.0+ redis: address: [ 127.0.0.1:16379 ] - username: + username: password: openIM123 ###################### Kafka ###################### @@ -81,13 +81,13 @@ redis: # It's not recommended to modify this topic name # Consumer group ID, it's not recommended to modify kafka: - username: - password: + username: + password: addr: [ 127.0.0.1:9092 ] latestMsgToRedis: - topic: "latestMsgToRedis" + topic: "latestMsgToRedis" offlineMsgToMongo: - topic: "offlineMsgToMongoMysql" + topic: "offlineMsgToMongoMysql" msgToPush: topic: "msgToPush" consumerGroupID: @@ -111,8 +111,8 @@ rpc: # API service port # Default listen IP is 0.0.0.0 api: - openImApiPort: [ 10002 ] - listenIP: 0.0.0.0 + openImApiPort: [ 10002 ] + listenIP: 0.0.0.0 ###################### Gateway ###################### # Object storage configuration @@ -124,25 +124,29 @@ api: # Session token # Configuration for Tencent COS # Configuration for Aliyun OSS +# apiURL is the address of the api, the access address of the app, use s3 must be configured +# minio.endpoint can be configured as an intranet address, +# minio.signEndpoint is minio public network address object: - enable: "minio" - apiURL: http://127.0.0.1:10002/object/ + enable: "minio" + apiURL: "http://127.0.0.1:10002" minio: - bucket: "openim" - endpoint: http://127.0.0.1:10005 - accessKeyID: root - secretAccessKey: openIM123 - sessionToken: "" - cos: + bucket: "openim" + endpoint: "http://127.0.0.1:10005" + accessKeyID: "root" + secretAccessKey: "openIM123" + sessionToken: "" + signEndpoint: "http://127.0.0.1:10005" + cos: bucketURL: "https://temp-1252357374.cos.ap-chengdu.myqcloud.com" secretID: "" secretKey: "" sessionToken: "" - oss: + oss: endpoint: "https://oss-cn-chengdu.aliyuncs.com" bucket: "demo-9999999" bucketURL: "https://demo-9999999.oss-cn-chengdu.aliyuncs.com" - accessKeyID: root + accessKeyID: "" accessKeySecret: "" sessionToken: "" @@ -150,7 +154,7 @@ object: # These ports are passed into the program by the script and are not recommended to modify # For launching multiple programs, just fill in multiple ports separated by commas # For example, [10110, 10111] -rpcPort: +rpcPort: openImUserPort: [ 10110 ] openImFriendPort: [ 10120 ] openImMessagePort: [ 10130 ] @@ -183,12 +187,12 @@ rpcRegisterName: # Whether to output in json format # Whether to include stack trace in logs log: - storageLocation: ../../../../../logs/ - rotationTime: 24 - remainRotationCount: 2 - remainLogLevel: 6 - isStdout: false - isJson: false + storageLocation: ../../../../../logs/ + rotationTime: 24 + remainRotationCount: 2 + remainLogLevel: 6 + isStdout: false + isJson: false withStack: false # Long connection server configuration @@ -198,10 +202,10 @@ log: # Maximum length of websocket request package # Websocket connection handshake timeout longConnSvr: - openImWsPort: [ 10001 ] - websocketMaxConnNum: 100000 - websocketMaxMsgLen: 4096 - websocketTimeout: 10 + openImWsPort: [ 10001 ] + websocketMaxConnNum: 100000 + websocketMaxMsgLen: 4096 + websocketTimeout: 10 # Push notification service configuration # diff --git a/go.mod b/go.mod index 4e875c741..4513cc7d2 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,7 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/testify v1.8.4 go.mongodb.org/mongo-driver v1.12.1 - golang.org/x/image v0.9.0 // indirect + golang.org/x/image v0.9.0 google.golang.org/api v0.135.0 google.golang.org/grpc v1.57.0 google.golang.org/protobuf v1.31.0 diff --git a/internal/api/third.go b/internal/api/third.go index 635e434f4..44a492fc4 100644 --- a/internal/api/third.go +++ b/internal/api/third.go @@ -83,7 +83,14 @@ func (o *ThirdApi) ObjectRedirect(c *gin.Context) { operationID = strconv.Itoa(rand.Int()) } ctx := mcontext.SetOperationID(c, operationID) - resp, err := o.Client.AccessURL(ctx, &third.AccessURLReq{Name: name}) + query := make(map[string]string) + for key, values := range c.Request.URL.Query() { + if len(values) == 0 { + continue + } + query[key] = values[0] + } + resp, err := o.Client.AccessURL(ctx, &third.AccessURLReq{Name: name, Query: query}) if err != nil { if errs.ErrArgs.Is(err) { c.String(http.StatusBadRequest, err.Error()) diff --git a/internal/rpc/third/s3.go b/internal/rpc/third/s3.go index 2278823cf..943cd5d54 100644 --- a/internal/rpc/third/s3.go +++ b/internal/rpc/third/s3.go @@ -16,8 +16,11 @@ package third import ( "context" + "strconv" "time" + "github.com/OpenIMSDK/Open-IM-Server/pkg/common/db/s3" + "github.com/OpenIMSDK/protocol/third" "github.com/OpenIMSDK/tools/errs" "github.com/OpenIMSDK/tools/log" @@ -152,7 +155,21 @@ func (t *thirdServer) CompleteMultipartUpload(ctx context.Context, req *third.Co } func (t *thirdServer) AccessURL(ctx context.Context, req *third.AccessURLReq) (*third.AccessURLResp, error) { - expireTime, rawURL, err := t.s3dataBase.AccessURL(ctx, req.Name, t.defaultExpire) + opt := &s3.AccessURLOption{} + if len(req.Query) > 0 { + switch req.Query["type"] { + case "": + case "image": + opt.Image = &s3.Image{} + opt.Image.Format = req.Query["format"] + opt.Image.Width, _ = strconv.Atoi(req.Query["width"]) + opt.Image.Height, _ = strconv.Atoi(req.Query["height"]) + log.ZDebug(ctx, "AccessURL image", "name", req.Name, "option", opt.Image) + default: + return nil, errs.ErrArgs.Wrap("invalid query type") + } + } + expireTime, rawURL, err := t.s3dataBase.AccessURL(ctx, req.Name, t.defaultExpire, opt) if err != nil { return nil, err } diff --git a/internal/rpc/third/third.go b/internal/rpc/third/third.go index 2cce73763..4c3bd32f8 100644 --- a/internal/rpc/third/third.go +++ b/internal/rpc/third/third.go @@ -49,6 +49,7 @@ func Start(client discoveryregistry.SvcDiscoveryRegistry, server *grpc.Server) e if apiURL[len(apiURL)-1] != '/' { apiURL += "/" } + apiURL += "object/" rdb, err := cache.NewRedis() if err != nil { return err diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 69c14eb66..638fbf17a 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -120,6 +120,7 @@ type configStruct struct { AccessKeyID string `yaml:"accessKeyID"` SecretAccessKey string `yaml:"secretAccessKey"` SessionToken string `yaml:"sessionToken"` + SignEndpoint string `yaml:"signEndpoint"` } `yaml:"minio"` Cos struct { BucketURL string `yaml:"bucketURL"` diff --git a/pkg/common/db/controller/s3.go b/pkg/common/db/controller/s3.go index 220e8e070..1ced644c2 100644 --- a/pkg/common/db/controller/s3.go +++ b/pkg/common/db/controller/s3.go @@ -30,7 +30,7 @@ type S3Database interface { AuthSign(ctx context.Context, uploadID string, partNumbers []int) (*s3.AuthSignResult, error) InitiateMultipartUpload(ctx context.Context, hash string, size int64, expire time.Duration, maxParts int) (*cont.InitiateUploadResult, error) CompleteMultipartUpload(ctx context.Context, uploadID string, parts []string) (*cont.UploadResult, error) - AccessURL(ctx context.Context, name string, expire time.Duration) (time.Time, string, error) + AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (time.Time, string, error) SetObject(ctx context.Context, info *relation.ObjectModel) error } @@ -70,14 +70,19 @@ func (s *s3Database) SetObject(ctx context.Context, info *relation.ObjectModel) return s.obj.SetObject(ctx, info) } -func (s *s3Database) AccessURL(ctx context.Context, name string, expire time.Duration) (time.Time, string, error) { +func (s *s3Database) AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (time.Time, string, error) { obj, err := s.obj.Take(ctx, name) if err != nil { return time.Time{}, "", err } - opt := &s3.AccessURLOption{ - ContentType: obj.ContentType, - Filename: filepath.Base(obj.Name), + if opt == nil { + opt = &s3.AccessURLOption{} + } + if opt.ContentType == "" { + opt.ContentType = obj.ContentType + } + if opt.Filename == "" { + opt.Filename = filepath.Base(obj.Name) } expireTime := time.Now().Add(expire) rawURL, err := s.s3.AccessURL(ctx, obj.Key, expire, opt) diff --git a/pkg/common/db/controller/user.go b/pkg/common/db/controller/user.go index 6981afd40..2957befee 100644 --- a/pkg/common/db/controller/user.go +++ b/pkg/common/db/controller/user.go @@ -18,9 +18,10 @@ import ( "context" "time" - unRelationTb "github.com/OpenIMSDK/Open-IM-Server/pkg/common/db/table/unrelation" "github.com/OpenIMSDK/protocol/user" + unRelationTb "github.com/OpenIMSDK/Open-IM-Server/pkg/common/db/table/unrelation" + "github.com/OpenIMSDK/tools/errs" "github.com/OpenIMSDK/tools/tx" "github.com/OpenIMSDK/tools/utils" diff --git a/pkg/common/db/s3/cont/controller.go b/pkg/common/db/s3/cont/controller.go index 675d3a513..891ecf38e 100644 --- a/pkg/common/db/s3/cont/controller.go +++ b/pkg/common/db/s3/cont/controller.go @@ -257,5 +257,9 @@ func (c *Controller) IsNotFound(err error) bool { } func (c *Controller) AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (string, error) { + if opt.Image != nil { + opt.Filename = "" + opt.ContentType = "" + } return c.impl.AccessURL(ctx, name, expire, opt) } diff --git a/pkg/common/db/s3/cos/cos.go b/pkg/common/db/s3/cos/cos.go index cfdaaa5e4..32f213161 100644 --- a/pkg/common/db/s3/cos/cos.go +++ b/pkg/common/db/s3/cos/cos.go @@ -36,6 +36,19 @@ const ( maxNumSize = 1000 ) +const ( + imagePng = "png" + imageJpg = "jpg" + imageJpeg = "jpeg" + imageGif = "gif" + imageWebp = "webp" +) + +const ( + videoSnapshotImagePng = "png" + videoSnapshotImageJpg = "jpg" +) + func NewCos() (s3.Interface, error) { conf := config.Config.Object.Cos u, err := url.Parse(conf.BucketURL) @@ -248,19 +261,44 @@ func (c *Cos) ListUploadedParts(ctx context.Context, uploadID string, name strin } func (c *Cos) AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (string, error) { - var option *cos.PresignedURLOptions + var imageMogr string + var option cos.PresignedURLOptions if opt != nil { query := make(url.Values) + if opt.Image != nil { + // https://cloud.tencent.com/document/product/436/44880 + style := make([]string, 0, 2) + wh := make([]string, 2) + if opt.Image.Width > 0 { + wh[0] = strconv.Itoa(opt.Image.Width) + } + if opt.Image.Height > 0 { + wh[1] = strconv.Itoa(opt.Image.Height) + } + if opt.Image.Width > 0 || opt.Image.Height > 0 { + style = append(style, strings.Join(wh, "x")) + } + switch opt.Image.Format { + case + imagePng, + imageJpg, + imageJpeg, + imageGif, + imageWebp: + style = append(style, "format/"+opt.Image.Format) + } + if len(style) > 0 { + imageMogr = "&imageMogr2/thumbnail/" + strings.Join(style, "/") + "/ignore-error/1" + } + } if opt.ContentType != "" { query.Set("response-content-type", opt.ContentType) } if opt.Filename != "" { - query.Set("response-content-disposition", `attachment; filename="`+opt.Filename+`"`) + query.Set("response-content-disposition", `attachment; filename=`+strconv.Quote(opt.Filename)) } if len(query) > 0 { - option = &cos.PresignedURLOptions{ - Query: &query, - } + option.Query = &query } } if expire <= 0 { @@ -268,9 +306,13 @@ func (c *Cos) AccessURL(ctx context.Context, name string, expire time.Duration, } else if expire < time.Second { expire = time.Second } - rawURL, err := c.client.Object.GetPresignedURL(ctx, http.MethodGet, name, c.credential.SecretID, c.credential.SecretKey, expire, option) + rawURL, err := c.client.Object.GetPresignedURL(ctx, http.MethodGet, name, c.credential.SecretID, c.credential.SecretKey, expire, &option) if err != nil { return "", err } - return rawURL.String(), nil + urlStr := rawURL.String() + if imageMogr != "" { + urlStr += imageMogr + } + return urlStr, nil } diff --git a/pkg/common/db/s3/minio/image.go b/pkg/common/db/s3/minio/image.go new file mode 100644 index 000000000..62d87551e --- /dev/null +++ b/pkg/common/db/s3/minio/image.go @@ -0,0 +1,106 @@ +package minio + +import ( + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" +) + +const ( + formatPng = "png" + formatJpeg = "jpeg" + formatJpg = "jpg" + formatGif = "gif" +) + +func ImageStat(reader io.Reader) (image.Image, string, error) { + return image.Decode(reader) +} + +func ImageWidthHeight(img image.Image) (int, int) { + bounds := img.Bounds().Max + return bounds.X, bounds.Y +} + +func resizeImage(img image.Image, maxWidth, maxHeight int) image.Image { + bounds := img.Bounds() + imgWidth := bounds.Max.X + imgHeight := bounds.Max.Y + + // 计算缩放比例 + scaleWidth := float64(maxWidth) / float64(imgWidth) + scaleHeight := float64(maxHeight) / float64(imgHeight) + + // 如果都为0,则不缩放,返回原始图片 + if maxWidth == 0 && maxHeight == 0 { + return img + } + + // 如果宽度和高度都大于0,则选择较小的缩放比例,以保持宽高比 + if maxWidth > 0 && maxHeight > 0 { + scale := scaleWidth + if scaleHeight < scaleWidth { + scale = scaleHeight + } + + // 计算缩略图尺寸 + thumbnailWidth := int(float64(imgWidth) * scale) + thumbnailHeight := int(float64(imgHeight) * scale) + + // 使用"image"库的Resample方法生成缩略图 + thumbnail := image.NewRGBA(image.Rect(0, 0, thumbnailWidth, thumbnailHeight)) + for y := 0; y < thumbnailHeight; y++ { + for x := 0; x < thumbnailWidth; x++ { + srcX := int(float64(x) / scale) + srcY := int(float64(y) / scale) + thumbnail.Set(x, y, img.At(srcX, srcY)) + } + } + + return thumbnail + } + + // 如果只指定了宽度或高度,则根据最大不超过的规则生成缩略图 + if maxWidth > 0 { + thumbnailWidth := maxWidth + thumbnailHeight := int(float64(imgHeight) * scaleWidth) + + // 使用"image"库的Resample方法生成缩略图 + thumbnail := image.NewRGBA(image.Rect(0, 0, thumbnailWidth, thumbnailHeight)) + for y := 0; y < thumbnailHeight; y++ { + for x := 0; x < thumbnailWidth; x++ { + srcX := int(float64(x) / scaleWidth) + srcY := int(float64(y) / scaleWidth) + thumbnail.Set(x, y, img.At(srcX, srcY)) + } + } + + return thumbnail + } + + if maxHeight > 0 { + thumbnailWidth := int(float64(imgWidth) * scaleHeight) + thumbnailHeight := maxHeight + + // 使用"image"库的Resample方法生成缩略图 + thumbnail := image.NewRGBA(image.Rect(0, 0, thumbnailWidth, thumbnailHeight)) + for y := 0; y < thumbnailHeight; y++ { + for x := 0; x < thumbnailWidth; x++ { + srcX := int(float64(x) / scaleHeight) + srcY := int(float64(y) / scaleHeight) + thumbnail.Set(x, y, img.At(srcX, srcY)) + } + } + + return thumbnail + } + + // 默认情况下,返回原始图片 + return img +} diff --git a/pkg/common/db/s3/minio/minio.go b/pkg/common/db/s3/minio/minio.go index bb6e6ce24..9137f7b75 100644 --- a/pkg/common/db/s3/minio/minio.go +++ b/pkg/common/db/s3/minio/minio.go @@ -15,16 +15,28 @@ package minio import ( + "bytes" "context" + "encoding/json" "errors" "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" "net/http" "net/url" + "path" + "path/filepath" + "reflect" "strconv" "strings" "sync" "time" + "unsafe" + "github.com/OpenIMSDK/tools/log" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/signer" @@ -43,6 +55,13 @@ const ( maxNumSize = 10000 ) +const ( + maxImageWidth = 1024 + maxImageHeight = 1024 + maxImageSize = 1024 * 1024 * 50 + pathInfo = "openim/thumbnail" +) + func NewMinio() (s3.Interface, error) { conf := config.Config.Object.Minio u, err := url.Parse(conf.Endpoint) @@ -60,11 +79,26 @@ func NewMinio() (s3.Interface, error) { m := &Minio{ bucket: conf.Bucket, bucketURL: conf.Endpoint + "/" + conf.Bucket + "/", - opts: opts, core: &minio.Core{Client: client}, lock: &sync.Mutex{}, init: false, } + if conf.SignEndpoint == "" || conf.SignEndpoint == conf.Endpoint { + m.sign = m.core.Client + } else { + su, err := url.Parse(conf.SignEndpoint) + if err != nil { + return nil, err + } + m.opts = &minio.Options{ + Creds: credentials.NewStaticV4(conf.AccessKeyID, conf.SecretAccessKey, conf.SessionToken), + Secure: su.Scheme == "https", + } + m.sign, err = minio.New(su.Host, m.opts) + if err != nil { + return nil, err + } + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := m.initMinio(ctx); err != nil { @@ -76,8 +110,10 @@ func NewMinio() (s3.Interface, error) { type Minio struct { bucket string bucketURL string + location string opts *minio.Options core *minio.Core + sign *minio.Client lock sync.Locker init bool } @@ -91,15 +127,43 @@ func (m *Minio) initMinio(ctx context.Context) error { if m.init { return nil } - exists, err := m.core.Client.BucketExists(ctx, config.Config.Object.Minio.Bucket) + conf := config.Config.Object.Minio + exists, err := m.core.Client.BucketExists(ctx, conf.Bucket) if err != nil { return fmt.Errorf("check bucket exists error: %w", err) } if !exists { - if err := m.core.Client.MakeBucket(ctx, config.Config.Object.Minio.Bucket, minio.MakeBucketOptions{}); err != nil { + if err := m.core.Client.MakeBucket(ctx, conf.Bucket, minio.MakeBucketOptions{}); err != nil { return fmt.Errorf("make bucket error: %w", err) } } + m.location, err = m.core.Client.GetBucketLocation(ctx, conf.Bucket) + if err != nil { + return err + } + func() { + if conf.SignEndpoint == "" || conf.SignEndpoint == conf.Endpoint { + return + } + defer func() { + if r := recover(); r != nil { + m.sign = m.core.Client + log.ZWarn( + context.Background(), + "set sign bucket location cache panic", + errors.New("failed to get private field value"), + "recover", + fmt.Sprintf("%+v", r), + "development version", + "github.com/minio/minio-go/v7 v7.0.61", + ) + } + }() + blc := reflect.ValueOf(m.sign).Elem().FieldByName("bucketLocCache") + vblc := reflect.New(reflect.PtrTo(blc.Type())) + *(*unsafe.Pointer)(vblc.UnsafePointer()) = unsafe.Pointer(blc.UnsafeAddr()) + vblc.Elem().Elem().Interface().(interface{ Set(string, string) }).Set(conf.Bucket, m.location) + }() m.init = true return nil } @@ -191,7 +255,7 @@ func (m *Minio) AuthSign(ctx context.Context, uploadID string, name string, expi return nil, err } request.Header.Set("X-Amz-Content-Sha256", unsignedPayload) - request = signer.SignV4Trailer(*request, creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken, "us-east-1", nil) + request = signer.SignV4Trailer(*request, creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken, m.location, nil) result.Parts[i] = s3.SignPart{ PartNumber: partNumber, URL: request.URL.String(), @@ -206,7 +270,7 @@ func (m *Minio) PresignedPutObject(ctx context.Context, name string, expire time if err := m.initMinio(ctx); err != nil { return "", err } - rawURL, err := m.core.Client.PresignedPutObject(ctx, m.bucket, name, expire) + rawURL, err := m.sign.PresignedPutObject(ctx, m.bucket, name, expire) if err != nil { return "", err } @@ -303,6 +367,19 @@ func (m *Minio) ListUploadedParts(ctx context.Context, uploadID string, name str return res, nil } +func (m *Minio) presignedGetObject(ctx context.Context, name string, expire time.Duration, query url.Values) (string, error) { + if expire <= 0 { + expire = time.Hour * 24 * 365 * 99 // 99 years + } else if expire < time.Second { + expire = time.Second + } + rawURL, err := m.sign.PresignedGetObject(ctx, m.bucket, name, expire, query) + if err != nil { + return "", err + } + return rawURL.String(), nil +} + func (m *Minio) AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (string, error) { if err := m.initMinio(ctx); err != nil { return "", err @@ -313,17 +390,123 @@ func (m *Minio) AccessURL(ctx context.Context, name string, expire time.Duration reqParams.Set("response-content-type", opt.ContentType) } if opt.Filename != "" { - reqParams.Set("response-content-disposition", `attachment; filename="`+opt.Filename+`"`) + reqParams.Set("response-content-disposition", `attachment; filename=`+strconv.Quote(opt.Filename)) } } - if expire <= 0 { - expire = time.Hour * 24 * 365 * 99 // 99 years - } else if expire < time.Second { - expire = time.Second + if opt.Image == nil || (opt.Image.Width < 0 && opt.Image.Height < 0 && opt.Image.Format == "") || (opt.Image.Width > maxImageWidth || opt.Image.Height > maxImageHeight) { + return m.presignedGetObject(ctx, name, expire, reqParams) } - u, err := m.core.Client.PresignedGetObject(ctx, m.bucket, name, expire, reqParams) + fileInfo, err := m.StatObject(ctx, name) if err != nil { return "", err } - return u.String(), nil + if fileInfo.Size > maxImageSize { + return "", errors.New("file size too large") + } + objectInfoPath := path.Join(pathInfo, fileInfo.ETag, "image.json") + var ( + img image.Image + info minioImageInfo + ) + data, err := m.getObjectData(ctx, objectInfoPath, 1024) + if err == nil { + if err := json.Unmarshal(data, &info); err != nil { + return "", fmt.Errorf("unmarshal minio image info.json error: %w", err) + } + if info.NotImage { + return "", errors.New("not image") + } + } else if m.IsNotFound(err) { + reader, err := m.core.Client.GetObject(ctx, m.bucket, name, minio.GetObjectOptions{}) + if err != nil { + return "", err + } + defer reader.Close() + imageInfo, format, err := ImageStat(reader) + if err == nil { + info.NotImage = false + info.Format = format + info.Width, info.Height = ImageWidthHeight(imageInfo) + img = imageInfo + } else { + info.NotImage = true + } + data, err := json.Marshal(&info) + if err != nil { + return "", err + } + if _, err := m.core.Client.PutObject(ctx, m.bucket, objectInfoPath, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{}); err != nil { + return "", err + } + } else { + return "", err + } + if opt.Image.Width > info.Width || opt.Image.Width <= 0 { + opt.Image.Width = info.Width + } + if opt.Image.Height > info.Height || opt.Image.Height <= 0 { + opt.Image.Height = info.Height + } + opt.Image.Format = strings.ToLower(opt.Image.Format) + if opt.Image.Format == formatJpg { + opt.Image.Format = formatJpeg + } + switch opt.Image.Format { + case formatPng: + case formatJpeg: + case formatGif: + default: + if info.Format == formatGif { + opt.Image.Format = formatGif + } else { + opt.Image.Format = formatJpeg + } + } + reqParams.Set("response-content-type", "image/"+opt.Image.Format) + if opt.Image.Width == info.Width && opt.Image.Height == info.Height && opt.Image.Format == info.Format { + return m.presignedGetObject(ctx, name, expire, reqParams) + } + cacheKey := filepath.Join(pathInfo, fileInfo.ETag, fmt.Sprintf("image_w%d_h%d.%s", opt.Image.Width, opt.Image.Height, opt.Image.Format)) + if _, err := m.core.Client.StatObject(ctx, m.bucket, cacheKey, minio.StatObjectOptions{}); err == nil { + return m.presignedGetObject(ctx, cacheKey, expire, reqParams) + } else if !m.IsNotFound(err) { + return "", err + } + if img == nil { + reader, err := m.core.Client.GetObject(ctx, m.bucket, name, minio.GetObjectOptions{}) + if err != nil { + return "", err + } + defer reader.Close() + img, _, err = ImageStat(reader) + if err != nil { + return "", err + } + } + thumbnail := resizeImage(img, opt.Image.Width, opt.Image.Height) + buf := bytes.NewBuffer(nil) + switch opt.Image.Format { + case formatPng: + err = png.Encode(buf, thumbnail) + case formatJpeg: + err = jpeg.Encode(buf, thumbnail, nil) + case formatGif: + err = gif.Encode(buf, thumbnail, nil) + } + if _, err := m.core.Client.PutObject(ctx, m.bucket, cacheKey, buf, int64(buf.Len()), minio.PutObjectOptions{}); err != nil { + return "", err + } + return m.presignedGetObject(ctx, cacheKey, expire, reqParams) +} + +func (m *Minio) getObjectData(ctx context.Context, name string, limit int64) ([]byte, error) { + object, err := m.core.Client.GetObject(ctx, m.bucket, name, minio.GetObjectOptions{}) + if err != nil { + return nil, err + } + defer object.Close() + if limit < 0 { + return io.ReadAll(object) + } + return io.ReadAll(io.LimitReader(object, 1024)) } diff --git a/pkg/common/db/s3/minio/struct.go b/pkg/common/db/s3/minio/struct.go new file mode 100644 index 000000000..8200a67b1 --- /dev/null +++ b/pkg/common/db/s3/minio/struct.go @@ -0,0 +1,8 @@ +package minio + +type minioImageInfo struct { + NotImage bool `json:"notImage,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Format string `json:"format,omitempty"` +} diff --git a/pkg/common/db/s3/oss/oss.go b/pkg/common/db/s3/oss/oss.go index 774150389..d84cad1c6 100644 --- a/pkg/common/db/s3/oss/oss.go +++ b/pkg/common/db/s3/oss/oss.go @@ -36,6 +36,19 @@ const ( maxNumSize = 10000 ) +const ( + imagePng = "png" + imageJpg = "jpg" + imageJpeg = "jpeg" + imageGif = "gif" + imageWebp = "webp" +) + +const ( + videoSnapshotImagePng = "png" + videoSnapshotImageJpg = "jpg" +) + func NewOSS() (s3.Interface, error) { conf := config.Config.Object.Oss if conf.BucketURL == "" { @@ -139,7 +152,7 @@ func (o *OSS) AuthSign(ctx context.Context, uploadID string, name string, expire } for i, partNumber := range partNumbers { rawURL := fmt.Sprintf(`%s%s?partNumber=%d&uploadId=%s`, o.bucketURL, name, partNumber, uploadID) - request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, nil) + request, err := http.NewRequest(http.MethodPut, rawURL, nil) if err != nil { return nil, err } @@ -150,12 +163,7 @@ func (o *OSS) AuthSign(ctx context.Context, uploadID string, name string, expire request.Header.Set(oss.HTTPHeaderHost, request.Host) request.Header.Set(oss.HTTPHeaderDate, now) request.Header.Set(oss.HttpHeaderOssDate, now) - authorization := fmt.Sprintf( - `OSS %s:%s`, - o.credentials.GetAccessKeyID(), - o.getSignedStr(request, fmt.Sprintf(`/%s/%s?partNumber=%d&uploadId=%s`, o.bucket.BucketName, name, partNumber, uploadID), o.credentials.GetAccessKeySecret()), - ) - request.Header.Set(oss.HTTPHeaderAuthorization, authorization) + ossSignHeader(o.bucket.Client.Conn, request, fmt.Sprintf(`/%s/%s?partNumber=%d&uploadId=%s`, o.bucket.BucketName, name, partNumber, uploadID)) delete(request.Header, oss.HTTPHeaderDate) result.Parts[i] = s3.SignPart{ PartNumber: partNumber, @@ -266,11 +274,36 @@ func (o *OSS) ListUploadedParts(ctx context.Context, uploadID string, name strin func (o *OSS) AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (string, error) { var opts []oss.Option if opt != nil { + if opt.Image != nil { + // 文档地址: https://help.aliyun.com/zh/oss/user-guide/resize-images-4?spm=a2c4g.11186623.0.0.4b3b1e4fWW6yji + var format string + switch opt.Image.Format { + case + imagePng, + imageJpg, + imageJpeg, + imageGif, + imageWebp: + format = opt.Image.Format + default: + opt.Image.Format = imageJpg + } + // https://oss-console-img-demo-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,h_100,m_lfit + process := "image/resize,m_lfit" + if opt.Image.Width > 0 { + process += ",w_" + strconv.Itoa(opt.Image.Width) + } + if opt.Image.Height > 0 { + process += ",h_" + strconv.Itoa(opt.Image.Height) + } + process += ",format," + format + opts = append(opts, oss.Process(process)) + } if opt.ContentType != "" { opts = append(opts, oss.ResponseContentType(opt.ContentType)) } if opt.Filename != "" { - opts = append(opts, oss.ResponseContentDisposition(`attachment; filename="`+opt.Filename+`"`)) + opts = append(opts, oss.ResponseContentDisposition(`attachment; filename=`+strconv.Quote(opt.Filename))) } } if expire <= 0 { diff --git a/pkg/common/db/s3/oss/sign.go b/pkg/common/db/s3/oss/sign.go index 9811ac476..60ce43a3e 100644 --- a/pkg/common/db/s3/oss/sign.go +++ b/pkg/common/db/s3/oss/sign.go @@ -1,96 +1,11 @@ -// Copyright © 2023 OpenIM. All rights reserved. -// -// Licensed 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 oss import ( - "crypto/hmac" - "crypto/sha1" - "crypto/sha256" - "encoding/base64" - "hash" - "io" "net/http" - "sort" - "strings" + _ "unsafe" "github.com/aliyun/aliyun-oss-go-sdk/oss" ) -func (o *OSS) getAdditionalHeaderKeys(req *http.Request) ([]string, map[string]string) { - var keysList []string - keysMap := make(map[string]string) - srcKeys := make(map[string]string) - - for k := range req.Header { - srcKeys[strings.ToLower(k)] = "" - } - - for _, v := range o.bucket.Client.Config.AdditionalHeaders { - if _, ok := srcKeys[strings.ToLower(v)]; ok { - keysMap[strings.ToLower(v)] = "" - } - } - - for k := range keysMap { - keysList = append(keysList, k) - } - sort.Strings(keysList) - return keysList, keysMap -} - -func (o *OSS) getSignedStr(req *http.Request, canonicalizedResource string, keySecret string) string { - // Find out the "x-oss-"'s address in header of the request - ossHeadersMap := make(map[string]string) - additionalList, additionalMap := o.getAdditionalHeaderKeys(req) - for k, v := range req.Header { - if strings.HasPrefix(strings.ToLower(k), "x-oss-") { - ossHeadersMap[strings.ToLower(k)] = v[0] - } else if o.bucket.Client.Config.AuthVersion == oss.AuthV2 { - if _, ok := additionalMap[strings.ToLower(k)]; ok { - ossHeadersMap[strings.ToLower(k)] = v[0] - } - } - } - hs := newHeaderSorter(ossHeadersMap) - - // Sort the ossHeadersMap by the ascending order - hs.Sort() - - // Get the canonicalizedOSSHeaders - canonicalizedOSSHeaders := "" - for i := range hs.Keys { - canonicalizedOSSHeaders += hs.Keys[i] + ":" + hs.Vals[i] + "\n" - } - - // Give other parameters values - // when sign URL, date is expires - date := req.Header.Get(oss.HTTPHeaderDate) - contentType := req.Header.Get(oss.HTTPHeaderContentType) - contentMd5 := req.Header.Get(oss.HTTPHeaderContentMD5) - - // default is v1 signature - signStr := req.Method + "\n" + contentMd5 + "\n" + contentType + "\n" + date + "\n" + canonicalizedOSSHeaders + canonicalizedResource - h := hmac.New(func() hash.Hash { return sha1.New() }, []byte(keySecret)) - - // v2 signature - if o.bucket.Client.Config.AuthVersion == oss.AuthV2 { - signStr = req.Method + "\n" + contentMd5 + "\n" + contentType + "\n" + date + "\n" + canonicalizedOSSHeaders + strings.Join(additionalList, ";") + "\n" + canonicalizedResource - h = hmac.New(func() hash.Hash { return sha256.New() }, []byte(keySecret)) - } - _, _ = io.WriteString(h, signStr) - signedStr := base64.StdEncoding.EncodeToString(h.Sum(nil)) - - return signedStr -} +//go:linkname ossSignHeader github.com/aliyun/aliyun-oss-go-sdk/oss.(*Conn).signHeader +func ossSignHeader(c *oss.Conn, req *http.Request, canonicalizedResource string) diff --git a/pkg/common/db/s3/oss/sort.go b/pkg/common/db/s3/oss/sort.go deleted file mode 100644 index 667984ffb..000000000 --- a/pkg/common/db/s3/oss/sort.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright © 2023 OpenIM. All rights reserved. -// -// Licensed 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 oss - -import ( - "bytes" - "sort" -) - -// headerSorter defines the key-value structure for storing the sorted data in signHeader. -type headerSorter struct { - Keys []string - Vals []string -} - -// newHeaderSorter is an additional function for function SignHeader. -func newHeaderSorter(m map[string]string) *headerSorter { - hs := &headerSorter{ - Keys: make([]string, 0, len(m)), - Vals: make([]string, 0, len(m)), - } - - for k, v := range m { - hs.Keys = append(hs.Keys, k) - hs.Vals = append(hs.Vals, v) - } - return hs -} - -// Sort is an additional function for function SignHeader. -func (hs *headerSorter) Sort() { - sort.Sort(hs) -} - -// Len is an additional function for function SignHeader. -func (hs *headerSorter) Len() int { - return len(hs.Vals) -} - -// Less is an additional function for function SignHeader. -func (hs *headerSorter) Less(i, j int) bool { - return bytes.Compare([]byte(hs.Keys[i]), []byte(hs.Keys[j])) < 0 -} - -// Swap is an additional function for function SignHeader. -func (hs *headerSorter) Swap(i, j int) { - hs.Vals[i], hs.Vals[j] = hs.Vals[j], hs.Vals[i] - hs.Keys[i], hs.Keys[j] = hs.Keys[j], hs.Keys[i] -} diff --git a/pkg/common/db/s3/s3.go b/pkg/common/db/s3/s3.go index fadb09a0b..afbe91955 100644 --- a/pkg/common/db/s3/s3.go +++ b/pkg/common/db/s3/s3.go @@ -116,9 +116,16 @@ type ListUploadedPartsResult struct { UploadedParts []UploadedPart `xml:"Part"` } +type Image struct { + Format string `json:"format"` + Width int `json:"width"` + Height int `json:"height"` +} + type AccessURLOption struct { ContentType string `json:"contentType"` Filename string `json:"filename"` + Image *Image `json:"image"` } type Interface interface { diff --git a/pkg/common/db/unrelation/user.go b/pkg/common/db/unrelation/user.go index 4f1bbd017..3faec873f 100644 --- a/pkg/common/db/unrelation/user.go +++ b/pkg/common/db/unrelation/user.go @@ -16,12 +16,14 @@ package unrelation import ( "context" - "github.com/OpenIMSDK/Open-IM-Server/pkg/common/db/table/unrelation" + "github.com/OpenIMSDK/tools/errs" "github.com/OpenIMSDK/tools/utils" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + + "github.com/OpenIMSDK/Open-IM-Server/pkg/common/db/table/unrelation" ) // prefixes and suffixes.