package file import ( "bytes" "context" "errors" "fmt" "io" "mime/multipart" "net/http" "net/url" "strings" ) var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrInvalidFile = errors.New("invalid file") ) const maxUploadSize = 10 * 1024 * 1024 var allowedContentTypes = map[string]bool{ "image/jpeg": true, "image/png": true, "image/webp": true, "application/pdf": true, } type Service struct { storage *Storage } func NewService(storage *Storage) *Service { return &Service{storage: storage} } func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { if s.storage == nil { return nil, ErrDependencyUnavailable } if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 { return nil, ErrInvalidFile } data, err := readUploadData(req.Reader) if err != nil || len(data) == 0 || len(data) > maxUploadSize { return nil, ErrInvalidFile } contentType := normalizeContentType(req.ContentType, data) scene := normalizeScene(req.Scene) if !allowedContentTypes[contentType] || (scene == "chat" && !strings.HasPrefix(contentType, "image/")) { return nil, ErrInvalidFile } key, err := newObjectKey(scene, req.Header.Filename) if err != nil { return nil, err } if err := s.storage.PutObject(req.Context, key, bytes.NewReader(data), int64(len(data)), contentType, map[string]string{ "original-filename": req.Header.Filename, }); err != nil { return nil, fmt.Errorf("%w: %v", ErrDependencyUnavailable, err) } var thumbnailURL string var mediumURL string objectKeys := []string{key} for _, variant := range generateImageVariants(key, data, contentType) { err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{ "source-object": key, }) if err != nil { continue } objectKeys = append(objectKeys, variant.Key) if strings.Contains(variant.Key, "."+ImageVariantThumb+".") { thumbnailURL = fileURLForScene(scene, variant.Key) } if strings.Contains(variant.Key, "."+ImageVariantMedium+".") { mediumURL = fileURLForScene(scene, variant.Key) } } fileURL := fileURLForScene(scene, key) return &UploadDTO{ ObjectKey: key, URL: fileURL, ThumbnailURL: thumbnailURL, MediumURL: mediumURL, Filename: req.Header.Filename, ContentType: contentType, Size: int64(len(data)), objectKeys: objectKeys, }, nil } func readUploadData(reader multipart.File) ([]byte, error) { limited := io.LimitReader(reader, maxUploadSize+1) return io.ReadAll(limited) } func normalizeContentType(contentType string, data []byte) string { contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) if allowedContentTypes[contentType] { return contentType } detected := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])) if allowedContentTypes[detected] { return detected } return contentType } func fileURLForScene(scene string, key string) string { fileURL := "/api/files/object?key=" + url.QueryEscape(key) if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" { fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) } return fileURL } func normalizeScene(scene string) string { scene = strings.TrimSpace(strings.ToLower(scene)) switch scene { case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement": return scene default: return "misc" } } type uploadRequest struct { Context context.Context Scene string Header *multipart.FileHeader Reader multipart.File ContentType string }