package attachments import ( "bytes" "context" "crypto/md5" "crypto/rand" "encoding/hex" "errors" "io" "net/http" "net/url" "os" "path" "path/filepath" "strings" "time" "git.118im.com/im_team/olons_server/internal/config" "git.118im.com/im_team/olons_server/internal/store" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) const maxImageSize = 5 << 20 var ErrImageInvalid = errors.New("image attachment invalid") type Repository interface { CreateAttachment(context.Context, store.AttachmentCreation) (*store.Attachment, error) } type Service struct { repository Repository cfg config.UploadConfig minio *minio.Client } func New(repository Repository, cfg config.UploadConfig) (*Service, error) { service := &Service{repository: repository, cfg: cfg} if strings.EqualFold(cfg.Driver, "minio") { client, err := minio.New(strings.TrimSpace(cfg.Endpoint), &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: cfg.Secure, Region: cfg.Region, }) if err != nil { return nil, err } service.minio = client } return service, nil } func (s *Service) UploadImage(ctx context.Context, memberID int64, name string, reader io.Reader) (*store.Attachment, error) { if s.repository == nil || memberID <= 0 { return nil, ErrImageInvalid } data, err := io.ReadAll(io.LimitReader(reader, maxImageSize+1)) if err != nil || len(data) == 0 || len(data) > maxImageSize { return nil, ErrImageInvalid } mimeType := http.DetectContentType(data) extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} ext, ok := extensions[mimeType] if !ok { return nil, ErrImageInvalid } random := make([]byte, 16) if _, err = rand.Read(random); err != nil { return nil, err } fileName := hex.EncodeToString(random) + ext storedPath, publicURL, err := s.store(ctx, fileName, mimeType, data) if err != nil { return nil, err } digest := md5.Sum(data) attachment, err := s.repository.CreateAttachment(ctx, store.AttachmentCreation{ MemberID: memberID, Drive: strings.ToLower(strings.TrimSpace(s.cfg.Driver)), Name: filepath.Base(name), MimeType: mimeType, Path: storedPath, URL: publicURL, Size: int64(len(data)), Ext: strings.TrimPrefix(ext, "."), MD5: hex.EncodeToString(digest[:]), }) if err != nil { s.remove(ctx, storedPath) return nil, err } return attachment, nil } func (s *Service) store(ctx context.Context, fileName, mimeType string, data []byte) (string, string, error) { if s.minio != nil { objectName := path.Join(strings.Trim(s.cfg.Path, "/"), time.Now().UTC().Format("2006/01/02"), fileName) _, err := s.minio.PutObject(ctx, s.cfg.Bucket, objectName, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: mimeType}) if err != nil { return "", "", err } return objectName, minioPublicURL(s.cfg.Domain, s.cfg.Bucket, objectName), nil } if err := os.MkdirAll(s.cfg.Dir, 0o750); err != nil { return "", "", err } localPath := filepath.Join(s.cfg.Dir, fileName) if err := os.WriteFile(localPath, data, 0o640); err != nil { return "", "", err } return localPath, strings.TrimRight(s.cfg.PublicPath, "/") + "/" + fileName, nil } func (s *Service) remove(ctx context.Context, storedPath string) { if s.minio != nil { _ = s.minio.RemoveObject(ctx, s.cfg.Bucket, storedPath, minio.RemoveObjectOptions{}) return } _ = os.Remove(storedPath) } func minioPublicURL(domain, bucket, objectName string) string { base := strings.TrimRight(strings.TrimSpace(domain), "/") segments := strings.Split(strings.Trim(objectName, "/"), "/") for i := range segments { segments[i] = url.PathEscape(segments[i]) } return base + "/" + url.PathEscape(bucket) + "/" + strings.Join(segments, "/") }