408 lines
9.8 KiB
Go
408 lines
9.8 KiB
Go
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)
|