package artwork import ( "context" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/zeebo/xxh3" ) // ImageStore is the content-addressed store for artwork images with no library file backing them. type ImageStore struct { root string } func NewImageStore(rootDir string) *ImageStore { return &ImageStore{root: rootDir} } func GetImageStore() *ImageStore { return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder)) } // extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open. func extForMime(m string) string { switch m { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/gif": return ".gif" case "image/webp": return ".webp" } return ".img" } func hashImage(r io.Reader) (string, error) { d := xxh3.New() if _, err := io.Copy(d, r); err != nil { return "", err } return fmt.Sprintf("%016x", d.Sum64()), nil } // validHash guards path sharding: a malformed hash would slice-panic or inject path separators. func validHash(hash string) bool { if len(hash) != 16 { return false } for _, c := range []byte(hash) { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true } func (s *ImageStore) path(hash, mimeType string) string { return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) } func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { if !validHash(hash) { return fmt.Errorf("imagestore: invalid hash %q", hash) } dst := s.path(hash, mimeType) if _, err := os.Stat(dst); err == nil { // A touched mtime marks the file live so a concurrent prune spares it. now := time.Now() if err := os.Chtimes(dst, now, now); err == nil { return nil } // touch failed (likely pruned concurrently) — fall through and rewrite it } if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := io.Copy(tmp, r); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), dst) } func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { if !validHash(hash) { return nil, fmt.Errorf("imagestore: invalid hash %q", hash) } return os.Open(s.path(hash, mimeType)) } // Sweep removes store files not accepted by keep. Files modified after cutoff are always // kept: their acquisition row may not be committed yet. func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) { removed, failed := 0, 0 var lastErr error err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } if err := ctx.Err(); err != nil { return err } info, err := d.Info() if err != nil { return err } if info.ModTime().After(cutoff) { return nil } name := d.Name() remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window if !remove { ext := filepath.Ext(name) remove = !keep(strings.TrimSuffix(name, ext), ext) } if remove { // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks if err := os.Remove(path); err != nil { // One unremovable file must not strand the rest of the store until the next prune. failed, lastErr = failed+1, err return nil //nolint:nilerr // counted and reported in aggregate below } removed++ } return nil }) // Aggregated: a store that has gone read-only would otherwise warn once per file, every prune. if failed > 0 { log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr) } if errors.Is(err, fs.ErrNotExist) { return removed, nil } return removed, err }