package assetstore import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "image" "image/color" stddraw "image/draw" "image/jpeg" _ "image/png" "io" "mime" "os" "path/filepath" "strings" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/usecase" xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" ) const ( MaxInputBytes int64 = 20 << 20 MaxSourceDimension = 10000 MaxSourcePixels int64 = 25_000_000 MaxOutputDimension = 2048 JPEGQuality = 90 ) type Store struct { root string } func New(root string) (*Store, error) { absoluteRoot, err := filepath.Abs(root) if err != nil { return nil, errors.New("resolve asset storage root") } if err := os.MkdirAll(absoluteRoot, 0o700); err != nil { return nil, errors.New("create asset storage root") } info, err := os.Stat(absoluteRoot) if err != nil || !info.IsDir() { return nil, errors.New("asset storage root is not a directory") } return &Store{root: absoluteRoot}, nil } func (s *Store) Put( ctx context.Context, _ string, declaredMediaType string, content io.Reader, ) (usecase.NormalizedReferenceImage, error) { if content == nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorInvalid, errors.New("image content is required"), ) } inputFile, err := os.CreateTemp(s.root, ".input-*") if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } inputPath := inputFile.Name() defer func() { _ = inputFile.Close() _ = os.Remove(inputPath) }() inputHash := sha256.New() written, err := io.Copy( io.MultiWriter(inputFile, inputHash), io.LimitReader(&contextReader{ctx: ctx, reader: content}, MaxInputBytes+1), ) if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } if written > MaxInputBytes { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorTooLarge, errors.New("input image exceeds byte limit"), ) } if written == 0 { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorInvalid, errors.New("input image is empty"), ) } if err := inputFile.Sync(); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } if _, err := inputFile.Seek(0, io.SeekStart); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } config, format, err := image.DecodeConfig(inputFile) if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorInvalid, err, ) } inputMediaType, ok := mediaTypeForFormat(format) if !ok { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnsupported, errors.New("unsupported decoded image format"), ) } if !declaredTypeMatches(declaredMediaType, inputMediaType) { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnsupported, errors.New("declared and decoded media types differ"), ) } if config.Width <= 0 || config.Height <= 0 || config.Width > MaxSourceDimension || config.Height > MaxSourceDimension || int64(config.Width)*int64(config.Height) > MaxSourcePixels { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorInvalid, errors.New("input image dimensions are invalid"), ) } if _, err := inputFile.Seek(0, io.SeekStart); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } decoded, decodedFormat, err := image.Decode(inputFile) if err != nil || decodedFormat != format { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorInvalid, errors.New("input image cannot be fully decoded"), ) } normalized := normalizeToWhiteBackground(decoded) storageKey, err := randomStorageKey() if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } finalPath, err := s.resolve(storageKey) if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } if err := os.MkdirAll(filepath.Dir(finalPath), 0o700); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } outputFile, err := os.CreateTemp(filepath.Dir(finalPath), ".normalized-*") if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } outputPath := outputFile.Name() outputClosed := false defer func() { if !outputClosed { _ = outputFile.Close() } _ = os.Remove(outputPath) }() outputHash := sha256.New() if err := jpeg.Encode( io.MultiWriter(outputFile, outputHash), normalized, &jpeg.Options{Quality: JPEGQuality}, ); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } if err := outputFile.Sync(); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } if err := outputFile.Close(); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } outputClosed = true info, err := os.Stat(outputPath) if err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } if err := os.Rename(outputPath, finalPath); err != nil { return usecase.NormalizedReferenceImage{}, storeError( usecase.ImageStoreErrorUnavailable, err, ) } return usecase.NormalizedReferenceImage{ StorageKey: storageKey, InputMediaType: inputMediaType, InputSHA256: hex.EncodeToString(inputHash.Sum(nil)), MediaType: domain.NormalizedImageMediaType, SizeBytes: info.Size(), SHA256: hex.EncodeToString(outputHash.Sum(nil)), }, nil } func (s *Store) Open( ctx context.Context, storageKey string, ) (io.ReadCloser, error) { if err := ctx.Err(); err != nil { return nil, storeError(usecase.ImageStoreErrorUnavailable, err) } path, err := s.resolve(storageKey) if err != nil { return nil, storeError(usecase.ImageStoreErrorNotFound, err) } file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return nil, storeError(usecase.ImageStoreErrorNotFound, err) } if err != nil { return nil, storeError(usecase.ImageStoreErrorUnavailable, err) } return file, nil } func (s *Store) Delete( ctx context.Context, storageKey string, ) error { if err := ctx.Err(); err != nil { return storeError(usecase.ImageStoreErrorUnavailable, err) } path, err := s.resolve(storageKey) if err != nil { return storeError(usecase.ImageStoreErrorNotFound, err) } if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return storeError(usecase.ImageStoreErrorUnavailable, err) } return nil } func normalizeToWhiteBackground(source image.Image) *image.RGBA { sourceBounds := source.Bounds() width := sourceBounds.Dx() height := sourceBounds.Dy() outputWidth, outputHeight := boundedDimensions(width, height) output := image.NewRGBA(image.Rect(0, 0, outputWidth, outputHeight)) stddraw.Draw( output, output.Bounds(), &image.Uniform{C: color.White}, image.Point{}, stddraw.Src, ) if outputWidth == width && outputHeight == height { stddraw.Draw( output, output.Bounds(), source, sourceBounds.Min, stddraw.Over, ) return output } xdraw.CatmullRom.Scale( output, output.Bounds(), source, sourceBounds, stddraw.Over, nil, ) return output } func boundedDimensions(width, height int) (int, int) { longest := width if height > longest { longest = height } if longest <= MaxOutputDimension { return width, height } if width >= height { return MaxOutputDimension, maxInt( 1, int((int64(height)*MaxOutputDimension)/int64(width)), ) } return maxInt( 1, int((int64(width)*MaxOutputDimension)/int64(height)), ), MaxOutputDimension } func declaredTypeMatches(declared string, actual string) bool { mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(declared)) return err == nil && strings.EqualFold(mediaType, actual) } func mediaTypeForFormat(format string) (string, bool) { switch strings.ToLower(format) { case "jpeg": return "image/jpeg", true case "png": return "image/png", true case "webp": return "image/webp", true default: return "", false } } func randomStorageKey() (string, error) { var value [16]byte if _, err := rand.Read(value[:]); err != nil { return "", err } encoded := hex.EncodeToString(value[:]) return encoded[:2] + "/" + encoded + ".jpg", nil } func (s *Store) resolve(storageKey string) (string, error) { if storageKey == "" || strings.Contains(storageKey, `\`) || strings.HasPrefix(storageKey, "/") { return "", errors.New("unsafe storage key") } cleanKey := filepath.Clean(filepath.FromSlash(storageKey)) if cleanKey == "." || filepath.IsAbs(cleanKey) { return "", errors.New("unsafe storage key") } resolved := filepath.Join(s.root, cleanKey) relative, err := filepath.Rel(s.root, resolved) if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { return "", errors.New("unsafe storage key") } return resolved, nil } func storeError( kind usecase.ImageStoreErrorKind, cause error, ) error { return &usecase.ImageStoreError{Kind: kind, Cause: cause} } type contextReader struct { ctx context.Context reader io.Reader } func (r *contextReader) Read(buffer []byte) (int, error) { if err := r.ctx.Err(); err != nil { return 0, err } return r.reader.Read(buffer) } func maxInt(first, second int) int { if first > second { return first } return second } var _ usecase.ReferenceImageStore = (*Store)(nil)