Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
270 lines
6.3 KiB
Go
270 lines
6.3 KiB
Go
package download
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"yellowjacket/backend/system"
|
|
)
|
|
|
|
// Provider credentials — slskd API keys, Lidarr tokens, qBittorrent
|
|
// passwords — must not sit in the TOML config, which is world-readable
|
|
// by default and gets pasted into bug reports.
|
|
//
|
|
// They live in a separate 0600 JSON file in the user data directory.
|
|
// That is deliberately not encryption: a key stored beside the data it
|
|
// unlocks protects nothing, and pretending otherwise is worse than
|
|
// being clear about it. What the file mode does buy is protection from
|
|
// other local users and from the config file being shared casually.
|
|
//
|
|
// An OS keyring backend (libsecret / Keychain / DPAPI) is the right
|
|
// long-term answer and the Store interface exists so it can be added
|
|
// without touching any provider.
|
|
|
|
// ErrSecretNotFound is returned when a named secret has never been set.
|
|
var ErrSecretNotFound = errors.New("secret not found")
|
|
|
|
// secretsFileName is the store's file inside the user data directory.
|
|
const secretsFileName = "download-secrets.json"
|
|
|
|
// secretsFileMode is owner read/write only.
|
|
const secretsFileMode = 0o600
|
|
|
|
// SecretStore holds provider credentials.
|
|
type SecretStore interface {
|
|
// Get returns the secret for a provider's named field.
|
|
Get(providerID int64, name string) (string, error)
|
|
|
|
// Set stores a secret. An empty value deletes it.
|
|
Set(providerID int64, name, value string) error
|
|
|
|
// DeleteProvider removes every secret belonging to a provider.
|
|
DeleteProvider(providerID int64) error
|
|
}
|
|
|
|
// fileSecretStore is the default SecretStore: a 0600 JSON file.
|
|
type fileSecretStore struct {
|
|
path string
|
|
|
|
mu sync.RWMutex
|
|
loaded bool
|
|
data map[string]string
|
|
}
|
|
|
|
// NewFileSecretStore returns a SecretStore backed by a 0600 file in the
|
|
// user data directory.
|
|
func NewFileSecretStore() (SecretStore, error) {
|
|
dir, err := system.GetUserDataDirPath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve user data dir: %w", err)
|
|
}
|
|
|
|
return &fileSecretStore{
|
|
path: filepath.Join(dir, secretsFileName),
|
|
data: map[string]string{},
|
|
}, nil
|
|
}
|
|
|
|
// NewFileSecretStoreAt returns a store backed by an explicit path.
|
|
// Tests use this; production goes through NewFileSecretStore.
|
|
func NewFileSecretStoreAt(path string) SecretStore {
|
|
return &fileSecretStore{path: path, data: map[string]string{}}
|
|
}
|
|
|
|
// secretKey namespaces a secret by provider so two slskd instances do
|
|
// not share credentials.
|
|
func secretKey(providerID int64, name string) string {
|
|
return strconv.FormatInt(providerID, 10) + ":" + name
|
|
}
|
|
|
|
// load reads the file once. A missing file is an empty store, not an
|
|
// error — nothing has been configured yet.
|
|
func (s *fileSecretStore) load() error {
|
|
if s.loaded {
|
|
return nil
|
|
}
|
|
|
|
raw, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
s.data = map[string]string{}
|
|
s.loaded = true
|
|
|
|
return nil
|
|
}
|
|
|
|
return fmt.Errorf("read secrets file: %w", err)
|
|
}
|
|
|
|
data := map[string]string{}
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return fmt.Errorf("parse secrets file: %w", err)
|
|
}
|
|
|
|
s.data = data
|
|
s.loaded = true
|
|
|
|
return nil
|
|
}
|
|
|
|
// save writes the file atomically with restrictive permissions.
|
|
func (s *fileSecretStore) save() error {
|
|
raw, err := json.Marshal(s.data)
|
|
if err != nil {
|
|
return fmt.Errorf("encode secrets: %w", err)
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return fmt.Errorf("create secrets dir: %w", err)
|
|
}
|
|
|
|
// Write to a temp file in the same directory, chmod before the
|
|
// rename, so the secret is never briefly world-readable.
|
|
tmp := s.path + ".tmp"
|
|
|
|
if err := os.WriteFile(tmp, raw, secretsFileMode); err != nil {
|
|
return fmt.Errorf("write secrets file: %w", err)
|
|
}
|
|
|
|
if err := os.Chmod(tmp, secretsFileMode); err != nil {
|
|
_ = os.Remove(tmp)
|
|
|
|
return fmt.Errorf("chmod secrets file: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmp, s.path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
|
|
return fmt.Errorf("replace secrets file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Get returns a stored secret.
|
|
func (s *fileSecretStore) Get(providerID int64, name string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.load(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
v, ok := s.data[secretKey(providerID, name)]
|
|
if !ok {
|
|
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name)
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
// Set stores or clears a secret.
|
|
func (s *fileSecretStore) Set(providerID int64, name, value string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.load(); err != nil {
|
|
return err
|
|
}
|
|
|
|
key := secretKey(providerID, name)
|
|
|
|
if value == "" {
|
|
delete(s.data, key)
|
|
} else {
|
|
s.data[key] = value
|
|
}
|
|
|
|
return s.save()
|
|
}
|
|
|
|
// DeleteProvider drops every secret for a provider.
|
|
func (s *fileSecretStore) DeleteProvider(providerID int64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if err := s.load(); err != nil {
|
|
return err
|
|
}
|
|
|
|
prefix := strconv.FormatInt(providerID, 10) + ":"
|
|
|
|
for k := range s.data {
|
|
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
|
|
delete(s.data, k)
|
|
}
|
|
}
|
|
|
|
return s.save()
|
|
}
|
|
|
|
// lookupFor binds a store to one provider, producing the SecretLookup
|
|
// handed to constructors.
|
|
func lookupFor(store SecretStore, providerID int64) SecretLookup {
|
|
return func(name string) (string, error) {
|
|
if store == nil {
|
|
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name)
|
|
}
|
|
|
|
return store.Get(providerID, name)
|
|
}
|
|
}
|
|
|
|
// memSecretStore is an in-memory SecretStore for tests.
|
|
type memSecretStore struct {
|
|
mu sync.RWMutex
|
|
data map[string]string
|
|
}
|
|
|
|
// NewMemSecretStore returns an in-memory SecretStore.
|
|
func NewMemSecretStore() SecretStore {
|
|
return &memSecretStore{data: map[string]string{}}
|
|
}
|
|
|
|
func (s *memSecretStore) Get(providerID int64, name string) (string, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
v, ok := s.data[secretKey(providerID, name)]
|
|
if !ok {
|
|
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, name)
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
func (s *memSecretStore) Set(providerID int64, name, value string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if value == "" {
|
|
delete(s.data, secretKey(providerID, name))
|
|
|
|
return nil
|
|
}
|
|
|
|
s.data[secretKey(providerID, name)] = value
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *memSecretStore) DeleteProvider(providerID int64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
prefix := strconv.FormatInt(providerID, 10) + ":"
|
|
|
|
for k := range s.data {
|
|
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
|
|
delete(s.data, k)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|