package art import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "image" "os" "os/exec" "path/filepath" "regexp" "strconv" "time" ) // Prepared images are downloaded or uploaded covers converted to a clean // JPEG, waiting to be applied. They live in a cache folder under a random // token and expire after a few hours. type Prepared struct { Token string `json:"token"` Width int `json:"width"` Height int `json:"height"` Size int64 `json:"size"` Square bool `json:"square"` } type Store struct { Dir string FFmpeg string } var tokenRe = regexp.MustCompile(`^[0-9a-f]{32}$`) var ErrBadToken = errors.New("unknown or expired image; choose it again") const preparedTTL = 3 * time.Hour // Prepare converts image bytes to a JPEG no larger than maxSize pixels on // its longest side (0 keeps the original size). func (s *Store) Prepare(ctx context.Context, data []byte, maxSize int) (Prepared, error) { if s.FFmpeg == "" { return Prepared{}, errors.New("preparing images needs ffmpeg") } if err := os.MkdirAll(s.Dir, 0o750); err != nil { return Prepared{}, err } s.cleanup() var b [16]byte if _, err := rand.Read(b[:]); err != nil { return Prepared{}, err } token := hex.EncodeToString(b[:]) raw := filepath.Join(s.Dir, token+".src") out := filepath.Join(s.Dir, token+".jpg") defer os.Remove(raw) if err := os.WriteFile(raw, data, 0o640); err != nil { return Prepared{}, err } scale := "scale=iw:ih" if maxSize > 0 { m := strconv.Itoa(maxSize) scale = "scale='if(gt(iw,ih),min(" + m + ",iw),-2)':'if(gt(iw,ih),-2,min(" + m + ",ih))'" } ctx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() cmd := exec.CommandContext(ctx, s.FFmpeg, "-v", "error", "-nostdin", "-y", "-i", raw, "-frames:v", "1", "-vf", scale+",format=yuvj420p", "-q:v", "2", "-f", "image2", out) if msg, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(out) return Prepared{}, fmt.Errorf("that file is not a usable image: %s", firstLine(string(msg))) } f, err := os.Open(out) if err != nil { return Prepared{}, err } cfg, _, err := image.DecodeConfig(f) f.Close() if err != nil { _ = os.Remove(out) return Prepared{}, fmt.Errorf("converted image unreadable: %w", err) } fi, _ := os.Stat(out) return Prepared{Token: token, Width: cfg.Width, Height: cfg.Height, Size: fi.Size(), Square: Square(cfg.Width, cfg.Height)}, nil } // Path returns the prepared JPEG for a token. func (s *Store) Path(token string) (string, error) { if !tokenRe.MatchString(token) { return "", ErrBadToken } p := filepath.Join(s.Dir, token+".jpg") fi, err := os.Stat(p) if err != nil || time.Since(fi.ModTime()) > preparedTTL { return "", ErrBadToken } return p, nil } func (s *Store) cleanup() { ents, _ := os.ReadDir(s.Dir) for _, en := range ents { if fi, err := en.Info(); err == nil && time.Since(fi.ModTime()) > preparedTTL { _ = os.Remove(filepath.Join(s.Dir, en.Name())) } } } func firstLine(s string) string { for i, r := range s { if r == '\n' { return s[:i] } } if len(s) > 200 { return s[:200] } return s }