package plugins import ( "context" "encoding/json" "errors" "fmt" "net/url" "sync" "drivervault/apiserver/internal/pb" ) // Store is where a Manager keeps plugin enable-state and global (L1) config. // // It deals in the raw JSON document rather than in records, so the Manager does // not care where that document lives: PocketBase in production, memory in tests. type Store interface { // Load returns the stored document. found is false when the store holds no // document yet — a fresh install, nothing configured. // // A returned error means the store could not be reached or read. It must // NOT be taken as "no settings": the Manager stays unloaded and refuses to // save, so a transient outage cannot overwrite settings it never read. Load(ctx context.Context) (data []byte, found bool, err error) // Save replaces the stored document. Save(ctx context.Context, data []byte) error // Describe names the store for log and error messages. Describe() string } // PluginSettingsField is the record field the document lives in. The org (L2) // and user (L3) layers of the cascade keep their plugin config in a field of // this name too (see internal/api/integrations.go), so the global layer is // stored the same way they are rather than being a special case. // // The documents are not identical in shape: L2/L3 hold per-plugin user config // only, while L1 additionally carries the enable state and the registration of // external plugins. Same mechanism and same field, different payload. const PluginSettingsField = "pluginSettings" // globalSettingsKey identifies the singleton record holding the global layer. // It is a fixed constant, never caller input. const globalSettingsKey = "global" // --- PocketBase-backed store (production) ----------------------------------- // pbStore keeps the document in a single record of the app_settings collection, // identified by key="global". This is the only store the server itself uses: // nothing about the plugin layer touches the filesystem. type pbStore struct { client *pb.Client collection string mu sync.Mutex id string // cached id of the singleton record, resolved on first Load/Save } // NewPocketBaseStore returns a Store backed by the singleton settings record in // the given collection. func NewPocketBaseStore(client *pb.Client, collection string) Store { return &pbStore{client: client, collection: collection} } func (s *pbStore) Describe() string { return "PocketBase " + s.collection + " (key=" + globalSettingsKey + ")" } // settingsRecord is the slice of the singleton record this store reads. type settingsRecord struct { ID string `json:"id"` PluginSettings json.RawMessage `json:"pluginSettings"` } func (s *pbStore) Load(ctx context.Context) ([]byte, bool, error) { if !s.client.Configured() { return nil, false, fmt.Errorf("%w: no PocketBase service account configured", errNotReady) } rec, found, err := s.find(ctx) if err != nil { return nil, false, err } if !found { return nil, false, nil } s.mu.Lock() s.id = rec.ID s.mu.Unlock() return rec.PluginSettings, true, nil } // find resolves the singleton record. A missing record is not an error. func (s *pbStore) find(ctx context.Context) (settingsRecord, bool, error) { q := url.Values{} q.Set("filter", "key='"+globalSettingsKey+"'") q.Set("perPage", "1") res, err := s.client.List(ctx, s.collection, q) if err != nil { if isNotFound(err) { // A 404 from a list means the collection itself is absent: listing an // existing but empty collection answers 200 with no items. Tagged // apart from a plain outage because retrying a read against a // collection that does not exist can never succeed — the caller has // to create it. Still a flavour of not-ready, so nothing is // overwritten in the meantime. return settingsRecord{}, false, fmt.Errorf("%w: %v", errNoCollection, err) } return settingsRecord{}, false, fmt.Errorf("%w: %v", errNotReady, err) } var items []settingsRecord if err := json.Unmarshal(res.Items, &items); err != nil { return settingsRecord{}, false, fmt.Errorf("%w: %v", errNotReady, err) } if len(items) == 0 { return settingsRecord{}, false, nil } return items[0], true, nil } func (s *pbStore) Save(ctx context.Context, data []byte) error { if !s.client.Configured() { return fmt.Errorf("%w: no PocketBase service account configured", errPersist) } s.mu.Lock() id := s.id s.mu.Unlock() // Resolve the record id if this process has not seen it yet (or if the // record was recreated behind our back). if id == "" { rec, found, err := s.find(ctx) if err != nil { return fmt.Errorf("%w: %v", errPersist, err) } if found { id = rec.ID } } payload := map[string]any{ "key": globalSettingsKey, PluginSettingsField: json.RawMessage(data), } if id != "" { if err := s.client.Update(ctx, s.collection, id, payload, nil); err != nil { if !isNotFound(err) { return fmt.Errorf("%w: %v", errPersist, err) } // The record was deleted since we resolved it; fall through and // recreate it rather than losing the save. s.mu.Lock() s.id = "" s.mu.Unlock() id = "" } else { s.mu.Lock() s.id = id s.mu.Unlock() return nil } } var created settingsRecord if err := s.client.Create(ctx, s.collection, payload, &created); err != nil { return fmt.Errorf("%w: %v", errPersist, err) } s.mu.Lock() s.id = created.ID s.mu.Unlock() return nil } // isNotFound reports whether err is a PocketBase 404. func isNotFound(err error) bool { var apiErr *pb.APIError return errors.As(err, &apiErr) && apiErr.Status == 404 } // --- In-memory store (tests) ------------------------------------------------ // memoryStore holds the document in memory. failLoad/failSave let a test drive // the not-ready and failed-save paths. type memoryStore struct { mu sync.Mutex data []byte found bool failLoad error failSave error } // NewMemoryStore returns a Store that keeps the document in memory, seeded with // document (nil for an empty store). Intended for tests. func NewMemoryStore(document []byte) Store { return &memoryStore{data: document, found: document != nil} } func (s *memoryStore) Describe() string { return "memory" } func (s *memoryStore) Load(ctx context.Context) ([]byte, bool, error) { s.mu.Lock() defer s.mu.Unlock() if s.failLoad != nil { return nil, false, s.failLoad } return s.data, s.found, nil } func (s *memoryStore) Save(ctx context.Context, data []byte) error { s.mu.Lock() defer s.mu.Unlock() if s.failSave != nil { return s.failSave } s.data = append([]byte(nil), data...) s.found = true return nil }