CI / ci (push) Successful in 1m49s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
270 lines
9.3 KiB
Go
270 lines
9.3 KiB
Go
package recipes
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/gif"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"io/fs"
|
|
"math"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
|
|
"github.com/oklog/ulid/v2"
|
|
"golang.org/x/image/draw"
|
|
"golang.org/x/image/webp"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
const (
|
|
// PhotoDir is where cached photos are stored, relative to the data
|
|
// directory.
|
|
PhotoDir = "media/photos"
|
|
// PhotoMinShortSide is the least a photo's shorter side may measure, in
|
|
// pixels.
|
|
PhotoMinShortSide = 300
|
|
// PhotoMaxPixels bounds the pixels a photo may decode to, so a small
|
|
// compressed file cannot claim gigabytes of memory.
|
|
PhotoMaxPixels = 40_000_000
|
|
// PhotoQuality is the JPEG quality of every stored size.
|
|
PhotoQuality = 82
|
|
)
|
|
|
|
// PhotoSizes are the long-side lengths, in pixels, each photo is stored at.
|
|
var PhotoSizes = []int{1200, 400}
|
|
|
|
var (
|
|
// ErrPhotoTooSmall is returned for an image whose shorter side is under
|
|
// PhotoMinShortSide.
|
|
ErrPhotoTooSmall = errors.New("recipes: photo is too small")
|
|
// ErrPhotoTooLarge is returned for an image over PhotoMaxPixels.
|
|
ErrPhotoTooLarge = errors.New("recipes: photo has too many pixels")
|
|
// ErrPhotoFormat is returned for bytes that are not a JPEG, PNG, WebP
|
|
// or GIF image.
|
|
ErrPhotoFormat = errors.New("recipes: photo is not a JPEG, PNG, WebP or GIF image")
|
|
)
|
|
|
|
// photoDecoders are the formats a photo may be in. A GIF decodes to its
|
|
// first frame.
|
|
var photoDecoders = map[string]func([]byte) (image.Image, error){
|
|
"jpeg": func(b []byte) (image.Image, error) { return jpeg.Decode(bytes.NewReader(b)) },
|
|
"png": func(b []byte) (image.Image, error) { return png.Decode(bytes.NewReader(b)) },
|
|
"gif": func(b []byte) (image.Image, error) { return gif.Decode(bytes.NewReader(b)) },
|
|
"webp": func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) },
|
|
}
|
|
|
|
// PhotoCache downloads photos through a Fetcher and stores each at every
|
|
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
|
|
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
|
|
// PhotoCache is safe for concurrent use.
|
|
type PhotoCache struct {
|
|
fetcher *Fetcher
|
|
store *store.Store
|
|
// dir is the absolute photo directory.
|
|
dir string
|
|
}
|
|
|
|
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
|
|
// the photo directory if it is absent.
|
|
func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCache, error) {
|
|
if fetcher == nil {
|
|
return nil, errors.New("recipes: nil fetcher")
|
|
}
|
|
if st == nil {
|
|
return nil, errors.New("recipes: nil store")
|
|
}
|
|
dir, err := filepath.Abs(filepath.Join(dataDir, filepath.FromSlash(PhotoDir)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recipes: resolve photo directory: %w", err)
|
|
}
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
return nil, fmt.Errorf("recipes: create photo directory: %w", err)
|
|
}
|
|
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
|
|
}
|
|
|
|
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
|
|
// ready for CachePhoto.
|
|
type DownloadedPhoto struct {
|
|
// url is the image's final URL, after redirects.
|
|
url string
|
|
sha256 string
|
|
img image.Image
|
|
}
|
|
|
|
// DownloadPhoto downloads the image at rawURL and decodes it, refusing one
|
|
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
|
|
// caller can try another image before opening a transaction.
|
|
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
|
|
page, err := c.fetcher.FetchImage(ctx, rawURL)
|
|
if err != nil {
|
|
return DownloadedPhoto{}, err
|
|
}
|
|
img, err := decodePhoto(page.Body)
|
|
if err != nil {
|
|
return DownloadedPhoto{}, fmt.Errorf("%w: %s", err, page.URL)
|
|
}
|
|
digest := sha256.Sum256(page.Body)
|
|
return DownloadedPhoto{url: page.URL, sha256: hex.EncodeToString(digest[:]), img: img}, nil
|
|
}
|
|
|
|
// CachePhoto records p in tx, the caller's transaction, so its photos row
|
|
// commits or rolls back with the rows that refer to it. An image whose
|
|
// bytes are already recorded returns that row and created false. Otherwise
|
|
// p is scaled so its long side is each of PhotoSizes, encoded as JPEG and
|
|
// recorded, and created is true: if tx then does not commit, the caller
|
|
// removes the files with RemovePhotoFiles. The row's width and height are
|
|
// the original image's, and its local_path is the largest size's file
|
|
// relative to the data directory.
|
|
//
|
|
// The store's writer has one connection, so transactions run one at a
|
|
// time and an image is never recorded twice.
|
|
func (c *PhotoCache) CachePhoto(ctx context.Context, tx *sql.Tx, p DownloadedPhoto) (photo db.Photo, created bool, err error) {
|
|
q := db.New(tx)
|
|
existing, err := q.GetPhotoBySHA256(ctx, p.sha256)
|
|
if err == nil {
|
|
return existing, false, nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return db.Photo{}, false, fmt.Errorf("recipes: look up photo: %w", err)
|
|
}
|
|
|
|
id := store.NewID()
|
|
for _, size := range PhotoSizes {
|
|
if err := writeJPEG(c.path(id, size), scaleLongSide(p.img, size)); err != nil {
|
|
return db.Photo{}, false, errors.Join(err, c.RemovePhotoFiles(id))
|
|
}
|
|
}
|
|
|
|
now := store.Now().String()
|
|
bounds := p.img.Bounds()
|
|
photo, err = q.CreatePhoto(ctx, db.CreatePhotoParams{
|
|
ID: id,
|
|
SourceUrl: p.url,
|
|
LocalPath: path.Join(PhotoDir, photoFileName(id, PhotoSizes[0])),
|
|
Width: int64(bounds.Dx()),
|
|
Height: int64(bounds.Dy()),
|
|
Sha256: p.sha256,
|
|
FetchedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
return db.Photo{}, false, errors.Join(fmt.Errorf("recipes: record photo: %w", err), c.RemovePhotoFiles(id))
|
|
}
|
|
return photo, true, nil
|
|
}
|
|
|
|
// RemovePhotoFiles removes the files of photo id at every PhotoSizes
|
|
// length; a file already absent is not an error.
|
|
func (c *PhotoCache) RemovePhotoFiles(id string) error {
|
|
var errs []error
|
|
for _, size := range PhotoSizes {
|
|
if err := os.Remove(c.path(id, size)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
errs = append(errs, err)
|
|
}
|
|
}
|
|
return errors.Join(errs...)
|
|
}
|
|
|
|
// OpenPhoto opens the stored file of photo id at size. An id that is not a
|
|
// canonical ULID, a size not in PhotoSizes, an id with no photos row, or a
|
|
// photo whose file is not stored is an error matching fs.ErrNotExist.
|
|
func (c *PhotoCache) OpenPhoto(ctx context.Context, id string, size int) (*os.File, error) {
|
|
parsed, err := ulid.ParseStrict(id)
|
|
if err != nil || parsed.String() != id || !slices.Contains(PhotoSizes, size) {
|
|
return nil, fmt.Errorf("recipes: photo %q at size %d: %w", id, size, fs.ErrNotExist)
|
|
}
|
|
if _, err := db.New(c.store.Reader).GetPhoto(ctx, id); errors.Is(err, sql.ErrNoRows) {
|
|
return nil, fmt.Errorf("recipes: photo %q: %w", id, fs.ErrNotExist)
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("recipes: look up photo %q: %w", id, err)
|
|
}
|
|
return os.Open(c.path(id, size))
|
|
}
|
|
|
|
func (c *PhotoCache) path(id string, size int) string {
|
|
return filepath.Join(c.dir, photoFileName(id, size))
|
|
}
|
|
|
|
func photoFileName(id string, size int) string {
|
|
return id + "-" + strconv.Itoa(size) + ".jpg"
|
|
}
|
|
|
|
// decodePhoto decodes a JPEG, PNG, WebP or GIF image, checking its pixel
|
|
// count from the header before decoding the pixels, and the shorter side of
|
|
// the decoded image: a GIF's first frame may be smaller than its screen.
|
|
func decodePhoto(b []byte) (image.Image, error) {
|
|
cfg, format, err := image.DecodeConfig(bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrPhotoFormat, err)
|
|
}
|
|
decode, ok := photoDecoders[format]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: format %q", ErrPhotoFormat, format)
|
|
}
|
|
if int64(cfg.Width)*int64(cfg.Height) > PhotoMaxPixels {
|
|
return nil, fmt.Errorf("%w: %dx%d", ErrPhotoTooLarge, cfg.Width, cfg.Height)
|
|
}
|
|
img, err := decode(b)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrPhotoFormat, err)
|
|
}
|
|
if size := img.Bounds().Size(); min(size.X, size.Y) < PhotoMinShortSide {
|
|
return nil, fmt.Errorf("%w: %dx%d, shorter side under %d px", ErrPhotoTooSmall, size.X, size.Y, PhotoMinShortSide)
|
|
}
|
|
return img, nil
|
|
}
|
|
|
|
// scaleLongSide returns img scaled so its longer side is long pixels,
|
|
// keeping its aspect ratio. Transparent areas become white, since JPEG has
|
|
// no alpha.
|
|
func scaleLongSide(img image.Image, long int) *image.RGBA {
|
|
b := img.Bounds()
|
|
w, h := b.Dx(), b.Dy()
|
|
dw, dh := long, long
|
|
if w >= h {
|
|
dh = max(1, int(math.Round(float64(h)*float64(long)/float64(w))))
|
|
} else {
|
|
dw = max(1, int(math.Round(float64(w)*float64(long)/float64(h))))
|
|
}
|
|
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
|
|
draw.Draw(dst, dst.Bounds(), image.White, image.Point{}, draw.Src)
|
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
|
return dst
|
|
}
|
|
|
|
// writeJPEG encodes img at PhotoQuality into a temporary file beside path
|
|
// and renames it into place, so a reader never sees a partial file.
|
|
func writeJPEG(path string, img image.Image) error {
|
|
f, err := os.CreateTemp(filepath.Dir(path), ".photo-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("recipes: create photo file: %w", err)
|
|
}
|
|
tmp := f.Name()
|
|
err = jpeg.Encode(f, img, &jpeg.Options{Quality: PhotoQuality})
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
err = errors.Join(err, f.Close())
|
|
if err == nil {
|
|
err = os.Rename(tmp, path)
|
|
}
|
|
if err != nil {
|
|
return errors.Join(fmt.Errorf("recipes: write photo %s: %w", filepath.Base(path), err), os.Remove(tmp))
|
|
}
|
|
return nil
|
|
}
|