github.com/openimsdk/tools@v0.0.49/s3/oss/oss.go (about) 1 // Copyright © 2023 OpenIM. All rights reserved. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package oss 16 17 import ( 18 "context" 19 "crypto/hmac" 20 "crypto/sha1" 21 "encoding/base64" 22 "encoding/json" 23 "errors" 24 "fmt" 25 "io" 26 "net/http" 27 "net/url" 28 "reflect" 29 "strconv" 30 "strings" 31 "time" 32 33 "github.com/openimsdk/tools/s3" 34 35 "github.com/aliyun/aliyun-oss-go-sdk/oss" 36 "github.com/openimsdk/tools/errs" 37 ) 38 39 const ( 40 minPartSize int64 = 1024 * 1024 * 1 // 1MB 41 maxPartSize int64 = 1024 * 1024 * 1024 * 5 // 5GB 42 maxNumSize int64 = 10000 43 ) 44 45 const ( 46 imagePng = "png" 47 imageJpg = "jpg" 48 imageJpeg = "jpeg" 49 imageGif = "gif" 50 imageWebp = "webp" 51 ) 52 53 const successCode = http.StatusOK 54 55 var _ s3.Interface = (*OSS)(nil) 56 57 type Config struct { 58 Endpoint string 59 Bucket string 60 BucketURL string 61 AccessKeyID string 62 AccessKeySecret string 63 SessionToken string 64 PublicRead bool 65 } 66 67 func NewOSS(conf Config) (*OSS, error) { 68 if conf.BucketURL == "" { 69 return nil, errs.Wrap(errors.New("bucket url is empty")) 70 } 71 client, err := oss.New(conf.Endpoint, conf.AccessKeyID, conf.AccessKeySecret) 72 if err != nil { 73 return nil, err 74 } 75 bucket, err := client.Bucket(conf.Bucket) 76 if err != nil { 77 return nil, errs.WrapMsg(err, "ali-oss bucket error") 78 } 79 if conf.BucketURL[len(conf.BucketURL)-1] != '/' { 80 conf.BucketURL += "/" 81 } 82 return &OSS{ 83 bucketURL: conf.BucketURL, 84 bucket: bucket, 85 credentials: client.Config.GetCredentials(), 86 um: *(*urlMaker)(reflect.ValueOf(bucket.Client.Conn).Elem().FieldByName("url").UnsafePointer()), 87 publicRead: conf.PublicRead, 88 }, nil 89 } 90 91 type OSS struct { 92 bucketURL string 93 bucket *oss.Bucket 94 credentials oss.Credentials 95 um urlMaker 96 publicRead bool 97 } 98 99 func (o *OSS) Engine() string { 100 return "ali-oss" 101 } 102 103 func (o *OSS) PartLimit() *s3.PartLimit { 104 return &s3.PartLimit{ 105 MinPartSize: minPartSize, 106 MaxPartSize: maxPartSize, 107 MaxNumSize: maxNumSize, 108 } 109 } 110 111 func (o *OSS) InitiateMultipartUpload(ctx context.Context, name string) (*s3.InitiateMultipartUploadResult, error) { 112 result, err := o.bucket.InitiateMultipartUpload(name) 113 if err != nil { 114 return nil, err 115 } 116 return &s3.InitiateMultipartUploadResult{ 117 UploadID: result.UploadID, 118 Bucket: result.Bucket, 119 Key: result.Key, 120 }, nil 121 } 122 123 func (o *OSS) CompleteMultipartUpload(ctx context.Context, uploadID string, name string, parts []s3.Part) (*s3.CompleteMultipartUploadResult, error) { 124 ossParts := make([]oss.UploadPart, len(parts)) 125 for i, part := range parts { 126 ossParts[i] = oss.UploadPart{ 127 PartNumber: part.PartNumber, 128 ETag: strings.ToUpper(part.ETag), 129 } 130 } 131 result, err := o.bucket.CompleteMultipartUpload(oss.InitiateMultipartUploadResult{ 132 UploadID: uploadID, 133 Bucket: o.bucket.BucketName, 134 Key: name, 135 }, ossParts) 136 if err != nil { 137 return nil, err 138 } 139 return &s3.CompleteMultipartUploadResult{ 140 Location: result.Location, 141 Bucket: result.Bucket, 142 Key: result.Key, 143 ETag: strings.ToLower(strings.ReplaceAll(result.ETag, `"`, ``)), 144 }, nil 145 } 146 147 func (o *OSS) PartSize(ctx context.Context, size int64) (int64, error) { 148 if size <= 0 { 149 return 0, errs.Wrap(errors.New("size must be greater than 0")) 150 } 151 if size > maxPartSize*maxNumSize { 152 return 0, errs.Wrap(errors.New("size must be less than the maximum allowed limit")) 153 } 154 if size <= minPartSize*maxNumSize { 155 return minPartSize, nil 156 } 157 partSize := size / maxNumSize 158 if size%maxNumSize != 0 { 159 partSize++ 160 } 161 return partSize, nil 162 } 163 164 func (o *OSS) AuthSign(ctx context.Context, uploadID string, name string, expire time.Duration, partNumbers []int) (*s3.AuthSignResult, error) { 165 result := s3.AuthSignResult{ 166 URL: o.bucketURL + name, 167 Query: url.Values{"uploadId": {uploadID}}, 168 Header: make(http.Header), 169 Parts: make([]s3.SignPart, len(partNumbers)), 170 } 171 for i, partNumber := range partNumbers { 172 rawURL := fmt.Sprintf(`%s%s?partNumber=%d&uploadId=%s`, o.bucketURL, name, partNumber, uploadID) 173 request, err := http.NewRequest(http.MethodPut, rawURL, nil) 174 if err != nil { 175 return nil, err 176 } 177 if o.credentials.GetSecurityToken() != "" { 178 request.Header.Set(oss.HTTPHeaderOssSecurityToken, o.credentials.GetSecurityToken()) 179 } 180 now := time.Now().UTC().Format(http.TimeFormat) 181 request.Header.Set(oss.HTTPHeaderHost, request.Host) 182 request.Header.Set(oss.HTTPHeaderDate, now) 183 request.Header.Set(oss.HttpHeaderOssDate, now) 184 signHeader(*o.bucket.Client.Conn, request, fmt.Sprintf(`/%s/%s?partNumber=%d&uploadId=%s`, o.bucket.BucketName, name, partNumber, uploadID), o.credentials) 185 delete(request.Header, oss.HTTPHeaderDate) 186 result.Parts[i] = s3.SignPart{ 187 PartNumber: partNumber, 188 Query: url.Values{"partNumber": {strconv.Itoa(partNumber)}}, 189 URL: request.URL.String(), 190 Header: request.Header, 191 } 192 } 193 return &result, nil 194 } 195 196 func (o *OSS) PresignedPutObject(ctx context.Context, name string, expire time.Duration) (string, error) { 197 return o.bucket.SignURL(name, http.MethodPut, int64(expire/time.Second)) 198 } 199 200 func (o *OSS) StatObject(ctx context.Context, name string) (*s3.ObjectInfo, error) { 201 header, err := o.bucket.GetObjectMeta(name) 202 if err != nil { 203 return nil, err 204 } 205 res := &s3.ObjectInfo{Key: name} 206 if res.ETag = strings.ToLower(strings.ReplaceAll(header.Get("ETag"), `"`, ``)); res.ETag == "" { 207 return nil, errs.Wrap(errors.New("StatObject etag not found")) 208 } 209 if contentLengthStr := header.Get("Content-Length"); contentLengthStr == "" { 210 return nil, errors.New("StatObject content-length not found") 211 } else { 212 res.Size, err = strconv.ParseInt(contentLengthStr, 10, 64) 213 if err != nil { 214 return nil, errs.WrapMsg(err, "StatObject content-length parse error") 215 } 216 if res.Size < 0 { 217 return nil, errs.Wrap(errors.New("StatObject content-length must be greater than 0")) 218 } 219 } 220 if lastModified := header.Get("Last-Modified"); lastModified == "" { 221 return nil, errs.Wrap(errors.New("StatObject last-modified not found")) 222 } else { 223 res.LastModified, err = time.Parse(http.TimeFormat, lastModified) 224 if err != nil { 225 return nil, errs.WrapMsg(err, "StatObject last-modified parse error") 226 } 227 } 228 return res, nil 229 } 230 231 func (o *OSS) DeleteObject(ctx context.Context, name string) error { 232 return o.bucket.DeleteObject(name) 233 } 234 235 func (o *OSS) CopyObject(ctx context.Context, src string, dst string) (*s3.CopyObjectInfo, error) { 236 result, err := o.bucket.CopyObject(src, dst) 237 if err != nil { 238 return nil, errs.WrapMsg(err, "CopyObject error") 239 } 240 return &s3.CopyObjectInfo{ 241 Key: dst, 242 ETag: strings.ToLower(strings.ReplaceAll(result.ETag, `"`, ``)), 243 }, nil 244 } 245 246 func (o *OSS) IsNotFound(err error) bool { 247 switch e := errs.Unwrap(err).(type) { 248 case oss.ServiceError: 249 return e.StatusCode == http.StatusNotFound || e.Code == "NoSuchKey" 250 case *oss.ServiceError: 251 return e.StatusCode == http.StatusNotFound || e.Code == "NoSuchKey" 252 default: 253 return false 254 } 255 } 256 257 func (o *OSS) AbortMultipartUpload(ctx context.Context, uploadID string, name string) error { 258 return o.bucket.AbortMultipartUpload(oss.InitiateMultipartUploadResult{ 259 UploadID: uploadID, 260 Key: name, 261 Bucket: o.bucket.BucketName, 262 }) 263 } 264 265 func (o *OSS) ListUploadedParts(ctx context.Context, uploadID string, name string, partNumberMarker int, maxParts int) (*s3.ListUploadedPartsResult, error) { 266 result, err := o.bucket.ListUploadedParts(oss.InitiateMultipartUploadResult{ 267 UploadID: uploadID, 268 Key: name, 269 Bucket: o.bucket.BucketName, 270 }, oss.MaxUploads(100), oss.MaxParts(maxParts), oss.PartNumberMarker(partNumberMarker)) 271 if err != nil { 272 return nil, errs.WrapMsg(err, "ListUploadedParts error") 273 } 274 res := &s3.ListUploadedPartsResult{ 275 Key: result.Key, 276 UploadID: result.UploadID, 277 MaxParts: result.MaxParts, 278 UploadedParts: make([]s3.UploadedPart, len(result.UploadedParts)), 279 } 280 res.NextPartNumberMarker, _ = strconv.Atoi(result.NextPartNumberMarker) 281 for i, part := range result.UploadedParts { 282 res.UploadedParts[i] = s3.UploadedPart{ 283 PartNumber: part.PartNumber, 284 LastModified: part.LastModified, 285 ETag: part.ETag, 286 Size: int64(part.Size), 287 } 288 } 289 return res, nil 290 } 291 292 func (o *OSS) AccessURL(ctx context.Context, name string, expire time.Duration, opt *s3.AccessURLOption) (string, error) { 293 var opts []oss.Option 294 if opt != nil { 295 if opt.Image != nil { 296 // Docs Address: https://help.aliyun.com/zh/oss/user-guide/resize-images-4?spm=a2c4g.11186623.0.0.4b3b1e4fWW6yji 297 var format string 298 switch opt.Image.Format { 299 case 300 imagePng, 301 imageJpg, 302 imageJpeg, 303 imageGif, 304 imageWebp: 305 format = opt.Image.Format 306 default: 307 opt.Image.Format = imageJpg 308 } 309 // https://oss-console-img-demo-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com/example.jpg?x-oss-process=image/resize,h_100,m_lfit 310 process := "image/resize,m_lfit" 311 if opt.Image.Width > 0 { 312 process += ",w_" + strconv.Itoa(opt.Image.Width) 313 } 314 if opt.Image.Height > 0 { 315 process += ",h_" + strconv.Itoa(opt.Image.Height) 316 } 317 process += ",format," + format 318 opts = append(opts, oss.Process(process)) 319 } 320 if !o.publicRead { 321 if opt.ContentType != "" { 322 opts = append(opts, oss.ResponseContentType(opt.ContentType)) 323 } 324 if opt.Filename != "" { 325 opts = append(opts, oss.ResponseContentDisposition(`attachment; filename=`+strconv.Quote(opt.Filename))) 326 } 327 } 328 } 329 if expire <= 0 { 330 expire = time.Hour * 24 * 365 * 99 // 99 years 331 } else if expire < time.Second { 332 expire = time.Second 333 } 334 if !o.publicRead { 335 return o.bucket.SignURL(name, http.MethodGet, int64(expire/time.Second), opts...) 336 } 337 rawParams, err := oss.GetRawParams(opts) 338 if err != nil { 339 return "", errs.WrapMsg(err, "AccessURL error") 340 } 341 params := getURLParams(*o.bucket.Client.Conn, rawParams) 342 return getURL(o.um, o.bucket.BucketName, name, params).String(), nil 343 } 344 345 func (o *OSS) FormData(ctx context.Context, name string, size int64, contentType string, duration time.Duration) (*s3.FormData, error) { 346 // https://help.aliyun.com/zh/oss/developer-reference/postobject?spm=a2c4g.11186623.0.0.1cb83cebkP55nn 347 expires := time.Now().Add(duration) 348 conditions := []any{ 349 map[string]string{"bucket": o.bucket.BucketName}, 350 map[string]string{"key": name}, 351 } 352 if size > 0 { 353 conditions = append(conditions, []any{"content-length-range", 0, size}) 354 } 355 policy := map[string]any{ 356 "expiration": expires.Format("2006-01-02T15:04:05.000Z"), 357 "conditions": conditions, 358 } 359 policyJson, err := json.Marshal(policy) 360 if err != nil { 361 return nil, errs.WrapMsg(err, "Marshal json error") 362 } 363 policyStr := base64.StdEncoding.EncodeToString(policyJson) 364 h := hmac.New(sha1.New, []byte(o.credentials.GetAccessKeySecret())) 365 if _, err := io.WriteString(h, policyStr); err != nil { 366 return nil, errs.WrapMsg(err, "WriteString error") 367 } 368 fd := &s3.FormData{ 369 URL: o.bucketURL, 370 File: "file", 371 Expires: expires, 372 FormData: map[string]string{ 373 "key": name, 374 "policy": policyStr, 375 "OSSAccessKeyId": o.credentials.GetAccessKeyID(), 376 "success_action_status": strconv.Itoa(successCode), 377 "signature": base64.StdEncoding.EncodeToString(h.Sum(nil)), 378 }, 379 SuccessCodes: []int{successCode}, 380 } 381 if contentType != "" { 382 fd.FormData["x-oss-content-type"] = contentType 383 } 384 return fd, nil 385 }