From 1a864490355a63404835ff069619bef34e2aa5f2 Mon Sep 17 00:00:00 2001 From: withchao <993506633@qq.com> Date: Tue, 8 Aug 2023 14:52:32 +0800 Subject: [PATCH] minio: preview image cache --- config/config.yaml | 4 - pkg/common/config/config.go | 14 +- pkg/common/db/s3/cont/controller.go | 2 +- pkg/common/db/s3/cos/cos.go | 25 ---- pkg/common/db/s3/minio/image.go | 105 ++++++++++++++ pkg/common/db/s3/minio/minio.go | 204 ++++++++++++++++++++-------- pkg/common/db/s3/minio/struct.go | 8 ++ pkg/common/db/s3/oss/oss.go | 23 ---- pkg/common/db/s3/s3.go | 8 -- 9 files changed, 269 insertions(+), 124 deletions(-) create mode 100644 pkg/common/db/s3/minio/image.go create mode 100644 pkg/common/db/s3/minio/struct.go diff --git a/config/config.yaml b/config/config.yaml index 3f848dc0b..035577b3d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -127,8 +127,6 @@ api: # 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 -# minio.thumbnailApi is the address of the thumbnail service, the access address of the app, https://github.com/OpenIMSDK/minio-ext -# minio.thumbnailUseSignEndpoint whether the url parameter for generating thumbnails uses a signed address, the default is fine object: enable: "minio" apiURL: "http://127.0.0.1:10002" @@ -139,8 +137,6 @@ object: secretAccessKey: "openIM123" sessionToken: "" signEndpoint: "http://127.0.0.1:10005" - thumbnailApi: "http://127.0.0.1:10003" - thumbnailUseSignEndpoint: false cos: bucketURL: "https://temp-1252357374.cos.ap-chengdu.myqcloud.com" secretID: "" diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go index 35dfea4c4..6f2fd1d61 100644 --- a/pkg/common/config/config.go +++ b/pkg/common/config/config.go @@ -114,14 +114,12 @@ type configStruct struct { Enable string `yaml:"enable"` ApiURL string `yaml:"apiURL"` Minio struct { - Bucket string `yaml:"bucket"` - Endpoint string `yaml:"endpoint"` - AccessKeyID string `yaml:"accessKeyID"` - SecretAccessKey string `yaml:"secretAccessKey"` - SessionToken string `yaml:"sessionToken"` - SignEndpoint string `yaml:"signEndpoint"` - ThumbnailApi string `yaml:"thumbnailApi"` - ThumbnailUseSignEndpoint bool `yaml:"thumbnailUseSignEndpoint"` + Bucket string `yaml:"bucket"` + Endpoint string `yaml:"endpoint"` + 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/s3/cont/controller.go b/pkg/common/db/s3/cont/controller.go index a3b78f4ba..a0045915a 100644 --- a/pkg/common/db/s3/cont/controller.go +++ b/pkg/common/db/s3/cont/controller.go @@ -256,7 +256,7 @@ 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.Video != nil { + if opt.Image != nil { opt.Filename = "" opt.ContentType = "" } diff --git a/pkg/common/db/s3/cos/cos.go b/pkg/common/db/s3/cos/cos.go index 66c083429..32f213161 100644 --- a/pkg/common/db/s3/cos/cos.go +++ b/pkg/common/db/s3/cos/cos.go @@ -262,7 +262,6 @@ 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 imageMogr string - //snapshot := make(url.Values) var option cos.PresignedURLOptions if opt != nil { query := make(url.Values) @@ -292,30 +291,6 @@ func (c *Cos) AccessURL(ctx context.Context, name string, expire time.Duration, imageMogr = "&imageMogr2/thumbnail/" + strings.Join(style, "/") + "/ignore-error/1" } } - if opt.Video != nil { - // 对象存储/桶存储列表/数据处理/媒体处理 开启 - // https://cloud.tencent.com/document/product/436/55671 - query.Set("ci-process", "snapshot") - sec := float64(opt.Video.Time/time.Millisecond) / 1000 - if sec < 0 { - sec = 0 - } - query.Set("time", strconv.FormatFloat(sec, 'f', 3, 64)) - switch opt.Video.Format { - case - videoSnapshotImagePng, - videoSnapshotImageJpg: - default: - opt.Video.Format = videoSnapshotImageJpg - } - query.Set("format", opt.Video.Format) - if opt.Video.Width > 0 { - query.Set("width", strconv.Itoa(opt.Video.Width)) - } - if opt.Video.Height > 0 { - query.Set("height", strconv.Itoa(opt.Video.Height)) - } - } if opt.ContentType != "" { query.Set("response-content-type", opt.ContentType) } diff --git a/pkg/common/db/s3/minio/image.go b/pkg/common/db/s3/minio/image.go new file mode 100644 index 000000000..e1f153f09 --- /dev/null +++ b/pkg/common/db/s3/minio/image.go @@ -0,0 +1,105 @@ +package minio + +import ( + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" +) + +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 b5d2167fc..0698cb9b9 100644 --- a/pkg/common/db/s3/minio/minio.go +++ b/pkg/common/db/s3/minio/minio.go @@ -15,16 +15,24 @@ package minio import ( + "bytes" "context" + "encoding/json" "errors" "fmt" - "github.com/OpenIMSDK/tools/errs" "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" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" "net/http" "net/url" + "path" + "path/filepath" "reflect" "strconv" "strings" @@ -46,6 +54,13 @@ const ( maxNumSize = 10000 ) +const ( + maxImageWidth = 1024 + maxImageHeight = 1024 + maxImageSize = 1024 * 1024 * 50 + pathInfo = "/minio/thumbnail" +) + func NewMinio() (s3.Interface, error) { conf := config.Config.Object.Minio u, err := url.Parse(conf.Endpoint) @@ -60,21 +75,12 @@ func NewMinio() (s3.Interface, error) { if err != nil { return nil, err } - imageApi := conf.ThumbnailApi - if imageApi != "" { - if imageApi[len(imageApi)-1] != '/' { - imageApi += "/" - } - imageApi += "image?" - } m := &Minio{ - bucket: conf.Bucket, - bucketURL: conf.Endpoint + "/" + conf.Bucket + "/", - imageApi: imageApi, - imageUseSignAddr: conf.ThumbnailUseSignEndpoint, - core: &minio.Core{Client: client}, - lock: &sync.Mutex{}, - init: false, + bucket: conf.Bucket, + bucketURL: conf.Endpoint + "/" + conf.Bucket + "/", + core: &minio.Core{Client: client}, + lock: &sync.Mutex{}, + init: false, } if conf.SignEndpoint == "" { m.sign = m.core.Client @@ -101,16 +107,14 @@ func NewMinio() (s3.Interface, error) { } type Minio struct { - bucket string - bucketURL string - imageApi string - imageUseSignAddr bool - location string - opts *minio.Options - core *minio.Core - sign *minio.Client - lock sync.Locker - init bool + bucket string + bucketURL string + location string + opts *minio.Options + core *minio.Core + sign *minio.Client + lock sync.Locker + init bool } func (m *Minio) initMinio(ctx context.Context) error { @@ -354,6 +358,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 @@ -367,43 +384,120 @@ func (m *Minio) AccessURL(ctx context.Context, name string, expire time.Duration 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) } - var client *minio.Client - if opt.Image == nil && opt.Video == nil { - client = m.sign - } else if m.imageUseSignAddr { - client = m.sign + fileInfo, err := m.StatObject(ctx, name) + if err != nil { + return "", err + } + 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 { - client = m.core.Client + return "", err } - u, err := client.PresignedGetObject(ctx, m.bucket, name, expire, reqParams) - if err != nil { + 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 opt.Image == nil && opt.Video == nil { - return u.String(), nil + 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 + } } - if m.imageApi == "" { - return "", errs.ErrInternalServer.Wrap("minio: thumbnail not configured") + 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 } - query := make(url.Values) - query.Set("url", u.String()) - if opt.Image != nil { - query.Set("type", "image") - query.Set("width", strconv.Itoa(opt.Image.Width)) - query.Set("height", strconv.Itoa(opt.Image.Height)) - query.Set("format", opt.Image.Format) + 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 } - if opt.Video != nil { - query.Set("type", "video") - query.Set("time", strconv.Itoa(int(opt.Video.Time/time.Millisecond))) - query.Set("width", strconv.Itoa(opt.Video.Width)) - query.Set("height", strconv.Itoa(opt.Video.Height)) - query.Set("format", opt.Video.Format) + defer object.Close() + if limit < 0 { + return io.ReadAll(object) } - return m.imageApi + query.Encode(), nil + 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 5d55b7096..d84cad1c6 100644 --- a/pkg/common/db/s3/oss/oss.go +++ b/pkg/common/db/s3/oss/oss.go @@ -299,29 +299,6 @@ func (o *OSS) AccessURL(ctx context.Context, name string, expire time.Duration, process += ",format," + format opts = append(opts, oss.Process(process)) } - if opt.Video != nil { - // 文档地址: https://help.aliyun.com/zh/oss/user-guide/video-snapshots?spm=a2c4g.11186623.0.0.23f743b0BR5WxX - // x-oss-process=video/snapshot,t_7000,f_jpg,w_800,h_600,m_fast - millisecond := int(opt.Video.Time / time.Millisecond) - if millisecond < 0 { - millisecond = 0 - } - switch opt.Video.Format { - case videoSnapshotImageJpg, videoSnapshotImagePng: - default: - opt.Video.Format = videoSnapshotImageJpg - } - process := "video/snapshot,t_" + strconv.Itoa(millisecond) + ",f_" + opt.Video.Format - if opt.Video.Width > 0 { - process += ",w_" + strconv.Itoa(opt.Video.Width) - } - if opt.Video.Height > 0 { - process += ",h_" + strconv.Itoa(opt.Video.Height) - } - process += ",ar_auto,m_fast" - fmt.Println(process) - opts = append(opts, oss.Process(process)) - } if opt.ContentType != "" { opts = append(opts, oss.ResponseContentType(opt.ContentType)) } diff --git a/pkg/common/db/s3/s3.go b/pkg/common/db/s3/s3.go index cb08813c6..afbe91955 100644 --- a/pkg/common/db/s3/s3.go +++ b/pkg/common/db/s3/s3.go @@ -122,18 +122,10 @@ type Image struct { Height int `json:"height"` } -type Video struct { - Width int `json:"width"` - Height int `json:"height"` - Time time.Duration `json:"time"` - Format string `json:"format"` -} - type AccessURLOption struct { ContentType string `json:"contentType"` Filename string `json:"filename"` Image *Image `json:"image"` - Video *Video `json:"video"` } type Interface interface {