mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-02 07:12:36 +00:00
* feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
379 lines
12 KiB
Go
379 lines
12 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/plugins/host"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
)
|
|
|
|
const (
|
|
defaultMaxKVStoreSize = 1 * 1024 * 1024 // 1MB default
|
|
maxKeyLength = 256 // Max key length in bytes
|
|
)
|
|
|
|
// notExpiredFilter is the SQL condition to exclude expired keys.
|
|
const notExpiredFilter = "(expires_at IS NULL OR expires_at >= datetime('now'))"
|
|
|
|
const cleanupInterval = 1 * time.Hour
|
|
|
|
// kvstoreServiceImpl implements the host.KVStoreService interface.
|
|
// Each plugin gets its own SQLite database for isolation.
|
|
type kvstoreServiceImpl struct {
|
|
pluginName string
|
|
db *sql.DB
|
|
maxSize int64
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database.
|
|
// The provided context controls the lifetime of the background cleanup goroutine.
|
|
func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) {
|
|
// Parse max size from permission, default to 1MB
|
|
maxSize := int64(defaultMaxKVStoreSize)
|
|
if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" {
|
|
parsed, err := humanize.ParseBytes(*perm.MaxSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid maxSize %q: %w", *perm.MaxSize, err)
|
|
}
|
|
maxSize = int64(parsed)
|
|
}
|
|
|
|
// Create plugin data directory
|
|
dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName)
|
|
if err := os.MkdirAll(dataDir, 0700); err != nil {
|
|
return nil, fmt.Errorf("creating plugin data directory: %w", err)
|
|
}
|
|
|
|
// Open SQLite database
|
|
dbPath := filepath.Join(dataDir, "kvstore.db")
|
|
db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening kvstore database: %w", err)
|
|
}
|
|
|
|
db.SetMaxOpenConns(3)
|
|
db.SetMaxIdleConns(1)
|
|
|
|
// Apply schema migrations
|
|
if err := createKVStoreSchema(db); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("migrating kvstore schema: %w", err)
|
|
}
|
|
|
|
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)))
|
|
|
|
cleanupCtx, cancel := context.WithCancel(ctx)
|
|
svc := &kvstoreServiceImpl{
|
|
pluginName: pluginName,
|
|
db: db,
|
|
maxSize: maxSize,
|
|
cancel: cancel,
|
|
}
|
|
svc.wg.Add(1)
|
|
go svc.cleanupLoop(cleanupCtx)
|
|
return svc, nil
|
|
}
|
|
|
|
// createKVStoreSchema applies schema migrations to the kvstore database.
|
|
// New migrations must be appended at the end of the slice.
|
|
func createKVStoreSchema(db *sql.DB) error {
|
|
return migrateDB(db, []string{
|
|
`CREATE TABLE IF NOT EXISTS kvstore (
|
|
key TEXT PRIMARY KEY NOT NULL,
|
|
value BLOB NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)`,
|
|
`ALTER TABLE kvstore ADD COLUMN expires_at DATETIME DEFAULT NULL`,
|
|
`CREATE INDEX idx_kvstore_expires_at ON kvstore(expires_at)`,
|
|
})
|
|
}
|
|
|
|
// storageUsed returns the current total storage used by non-expired keys.
|
|
func (s *kvstoreServiceImpl) storageUsed(ctx context.Context) (int64, error) {
|
|
var used int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size), 0) FROM kvstore WHERE `+notExpiredFilter).Scan(&used)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("calculating storage used: %w", err)
|
|
}
|
|
return used, nil
|
|
}
|
|
|
|
// checkStorageLimit verifies that adding delta bytes would not exceed the storage limit.
|
|
func (s *kvstoreServiceImpl) checkStorageLimit(ctx context.Context, delta int64) error {
|
|
if delta <= 0 {
|
|
return nil
|
|
}
|
|
used, err := s.storageUsed(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newTotal := used + delta
|
|
if newTotal > s.maxSize {
|
|
return fmt.Errorf("storage limit exceeded: would use %s of %s allowed",
|
|
humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// setValue is the shared implementation for Set and SetWithTTL.
|
|
// A ttlSeconds of 0 means no expiration.
|
|
func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
|
|
if len(key) == 0 {
|
|
return fmt.Errorf("key cannot be empty")
|
|
}
|
|
if len(key) > maxKeyLength {
|
|
return fmt.Errorf("key exceeds maximum length of %d bytes", maxKeyLength)
|
|
}
|
|
|
|
newValueSize := int64(len(value))
|
|
|
|
// Get current size of this key (if it exists and not expired) to calculate delta
|
|
var oldSize int64
|
|
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&oldSize)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("checking existing key: %w", err)
|
|
}
|
|
|
|
if err := s.checkStorageLimit(ctx, newValueSize-oldSize); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Compute expires_at: sql.NullString{Valid:false} sends NULL (no expiration),
|
|
// otherwise we send a concrete timestamp.
|
|
var expiresAt sql.NullString
|
|
if ttlSeconds > 0 {
|
|
expiresAt = sql.NullString{String: fmt.Sprintf("+%d seconds", ttlSeconds), Valid: true}
|
|
}
|
|
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO kvstore (key, value, size, created_at, updated_at, expires_at)
|
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, datetime('now', ?))
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
size = excluded.size,
|
|
updated_at = CURRENT_TIMESTAMP,
|
|
expires_at = excluded.expires_at
|
|
`, key, value, newValueSize, expiresAt)
|
|
if err != nil {
|
|
return fmt.Errorf("storing value: %w", err)
|
|
}
|
|
|
|
log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize, "ttlSeconds", ttlSeconds)
|
|
return nil
|
|
}
|
|
|
|
// Set stores a byte value with the given key.
|
|
func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error {
|
|
return s.setValue(ctx, key, value, 0)
|
|
}
|
|
|
|
// SetWithTTL stores a byte value with the given key and a time-to-live.
|
|
func (s *kvstoreServiceImpl) SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
|
|
if ttlSeconds <= 0 {
|
|
return fmt.Errorf("ttlSeconds must be greater than 0")
|
|
}
|
|
return s.setValue(ctx, key, value, ttlSeconds)
|
|
}
|
|
|
|
// Get retrieves a byte value from storage.
|
|
func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) {
|
|
var value []byte
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&value)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("reading value: %w", err)
|
|
}
|
|
|
|
log.Trace(ctx, "KVStore.Get", "plugin", s.pluginName, "key", key, "found", true)
|
|
return value, true, nil
|
|
}
|
|
|
|
// Delete removes a value from storage.
|
|
func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error {
|
|
_, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting value: %w", err)
|
|
}
|
|
|
|
log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key)
|
|
return nil
|
|
}
|
|
|
|
// Has checks if a key exists in storage.
|
|
func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) {
|
|
var count int
|
|
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&count)
|
|
if err != nil {
|
|
return false, fmt.Errorf("checking key: %w", err)
|
|
}
|
|
|
|
return count > 0, nil
|
|
}
|
|
|
|
// List returns all keys matching the given prefix.
|
|
func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string, error) {
|
|
var rows *sql.Rows
|
|
var err error
|
|
|
|
if prefix == "" {
|
|
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE `+notExpiredFilter+` ORDER BY key`)
|
|
} else {
|
|
// Escape special LIKE characters in prefix
|
|
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
|
|
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
|
|
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' AND `+notExpiredFilter+` ORDER BY key`, escapedPrefix+"%")
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing keys: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var keys []string
|
|
for rows.Next() {
|
|
var key string
|
|
if err := rows.Scan(&key); err != nil {
|
|
return nil, fmt.Errorf("scanning key: %w", err)
|
|
}
|
|
keys = append(keys, key)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterating keys: %w", err)
|
|
}
|
|
|
|
log.Trace(ctx, "KVStore.List", "plugin", s.pluginName, "prefix", prefix, "count", len(keys))
|
|
return keys, nil
|
|
}
|
|
|
|
// GetStorageUsed returns the total storage used by this plugin in bytes.
|
|
func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) {
|
|
used, err := s.storageUsed(ctx)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used)
|
|
return used, nil
|
|
}
|
|
|
|
// DeleteByPrefix removes all keys matching the given prefix.
|
|
func (s *kvstoreServiceImpl) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
|
|
if prefix == "" {
|
|
return 0, fmt.Errorf("prefix cannot be empty")
|
|
}
|
|
|
|
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
|
|
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
|
|
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key LIKE ? ESCAPE '\'`, escapedPrefix+"%")
|
|
if err != nil {
|
|
return 0, fmt.Errorf("deleting keys: %w", err)
|
|
}
|
|
|
|
count, err := result.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("getting deleted count: %w", err)
|
|
}
|
|
|
|
log.Trace(ctx, "KVStore.DeleteByPrefix", "plugin", s.pluginName, "prefix", prefix, "deletedCount", count)
|
|
return count, nil
|
|
}
|
|
|
|
// GetMany retrieves multiple values in a single call, processing keys in batches.
|
|
func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
|
|
if len(keys) == 0 {
|
|
return map[string][]byte{}, nil
|
|
}
|
|
|
|
const batchSize = 200
|
|
result := make(map[string][]byte)
|
|
for chunk := range slice.CollectChunks(slices.Values(keys), batchSize) {
|
|
placeholders := make([]string, len(chunk))
|
|
args := make([]any, len(chunk))
|
|
for i, key := range chunk {
|
|
placeholders[i] = "?"
|
|
args[i] = key
|
|
}
|
|
|
|
query := `SELECT key, value FROM kvstore WHERE key IN (` + strings.Join(placeholders, ",") + `) AND ` + notExpiredFilter //nolint:gosec // placeholders are always "?"
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("querying values: %w", err)
|
|
}
|
|
|
|
for rows.Next() {
|
|
var key string
|
|
var value []byte
|
|
if err := rows.Scan(&key, &value); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scanning value: %w", err)
|
|
}
|
|
result[key] = value
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("iterating values: %w", err)
|
|
}
|
|
rows.Close()
|
|
}
|
|
|
|
log.Trace(ctx, "KVStore.GetMany", "plugin", s.pluginName, "requested", len(keys), "found", len(result))
|
|
return result, nil
|
|
}
|
|
|
|
// cleanupLoop periodically removes expired keys from the database.
|
|
// It stops when the provided context is cancelled.
|
|
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
|
|
defer s.wg.Done()
|
|
ticker := time.NewTicker(cleanupInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.cleanupExpired(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// cleanupExpired removes all expired keys from the database to reclaim disk space.
|
|
func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) {
|
|
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
|
|
if err != nil {
|
|
log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err)
|
|
return
|
|
}
|
|
if count, err := result.RowsAffected(); err == nil && count > 0 {
|
|
log.Debug("KVStore cleanup completed", "plugin", s.pluginName, "deletedKeys", count)
|
|
}
|
|
}
|
|
|
|
// Close stops the cleanup goroutine and closes the SQLite database connection.
|
|
func (s *kvstoreServiceImpl) Close() error {
|
|
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
|
|
s.cancel()
|
|
s.wg.Wait()
|
|
return s.db.Close()
|
|
}
|
|
|
|
// Compile-time verification
|
|
var _ host.KVStoreService = (*kvstoreServiceImpl)(nil)
|