Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: boltdb cache for outh sessions

- Allow persisting sessions between arabica server restarts

pdewey.com f41de4cc f12c9311

verified
+635 -31
+4
.gitignore
··· 7 7 *.so 8 8 *.dylib 9 9 10 + 10 11 # Test binary 11 12 *.test 12 13 ··· 41 42 result 42 43 result-* 43 44 arabica 45 + 46 + # Other 47 + *.bak
+42 -5
cmd/server/main.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 "os" 7 + "path/filepath" 7 8 "time" 8 9 9 10 "arabica/internal/atproto" 11 + "arabica/internal/database/boltstore" 10 12 "arabica/internal/feed" 11 13 "arabica/internal/handlers" 12 14 "arabica/internal/routing" ··· 52 54 port = "18910" 53 55 } 54 56 55 - // Initialize OAuth manager 57 + // Initialize BoltDB store for persistent sessions and feed registry 58 + dbPath := os.Getenv("ARABICA_DB_PATH") 59 + if dbPath == "" { 60 + // Default to XDG data directory or home directory for development 61 + // This avoids issues when running from read-only locations (e.g., nix run) 62 + dataDir := os.Getenv("XDG_DATA_HOME") 63 + if dataDir == "" { 64 + home, err := os.UserHomeDir() 65 + if err != nil { 66 + log.Fatal().Err(err).Msg("Failed to get home directory") 67 + } 68 + dataDir = filepath.Join(home, ".local", "share") 69 + } 70 + dbPath = filepath.Join(dataDir, "arabica", "arabica.db") 71 + } 72 + 73 + store, err := boltstore.Open(boltstore.Options{ 74 + Path: dbPath, 75 + }) 76 + if err != nil { 77 + log.Fatal().Err(err).Str("path", dbPath).Msg("Failed to open database") 78 + } 79 + defer store.Close() 80 + 81 + log.Info().Str("path", dbPath).Msg("Database opened") 82 + 83 + // Get specialized stores 84 + sessionStore := store.SessionStore() 85 + feedStore := store.FeedStore() 86 + 87 + // Initialize OAuth manager with persistent session store 56 88 // For local development, localhost URLs trigger special localhost mode in indigo 57 89 clientID := os.Getenv("OAUTH_CLIENT_ID") 58 90 redirectURI := os.Getenv("OAUTH_REDIRECT_URI") ··· 64 96 log.Info().Msg("Using localhost OAuth mode (for development)") 65 97 } 66 98 67 - oauthManager, err := atproto.NewOAuthManager(clientID, redirectURI) 99 + oauthManager, err := atproto.NewOAuthManager(clientID, redirectURI, sessionStore) 68 100 if err != nil { 69 101 log.Fatal().Err(err).Msg("Failed to initialize OAuth") 70 102 } 71 103 72 - // Initialize feed registry and service for community feed 73 - feedRegistry := feed.NewRegistry() 104 + // Initialize feed registry with persistent store 105 + // This loads existing registered DIDs from the database 106 + feedRegistry := feed.NewPersistentRegistry(feedStore) 74 107 feedService := feed.NewService(feedRegistry) 75 - log.Info().Msg("Feed service initialized") 108 + 109 + log.Info(). 110 + Int("registered_users", feedRegistry.Count()). 111 + Msg("Feed service initialized with persistent registry") 76 112 77 113 // Register users in the feed when they authenticate 78 114 // This ensures users are added to the feed even if they had an existing session ··· 122 158 Str("address", "0.0.0.0:"+port). 123 159 Str("url", "http://localhost:"+port). 124 160 Bool("secure_cookies", secureCookies). 161 + Str("database", dbPath). 125 162 Msg("Starting HTTP server") 126 163 127 164 if err := http.ListenAndServe("0.0.0.0:"+port, handler); err != nil {
+23 -9
default.nix
··· 4 4 pname = "arabica"; 5 5 version = "0.1.0"; 6 6 src = ./.; 7 - vendorHash = "sha256-pB/TlsU2XnsLdQ63kExR2jIsVi2d21ITr6vRK25SfUk="; 7 + vendorHash = "sha256-5TKLHMHWaOTJqZKS+UtALR+bYh9gl6wroN/eRWauxY4="; 8 8 9 9 nativeBuildInputs = [ tailwindcss ]; 10 10 ··· 18 18 runHook postBuild 19 19 ''; 20 20 21 - installPhase = '' 21 + installPhase = 22 + let 23 + wrapperScript = '' 24 + #!/bin/sh 25 + SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" 26 + SHARE_DIR="$SCRIPT_DIR/../share/arabica" 27 + 28 + # Set default database path if not specified 29 + # Uses XDG_DATA_HOME or falls back to ~/.local/share 30 + if [ -z "$ARABICA_DB_PATH" ]; then 31 + DATA_DIR="''${XDG_DATA_HOME:-$HOME/.local/share}/arabica" 32 + mkdir -p "$DATA_DIR" 33 + export ARABICA_DB_PATH="$DATA_DIR/arabica.db" 34 + fi 35 + 36 + cd "$SHARE_DIR" 37 + exec "$SCRIPT_DIR/arabica-unwrapped" "$@" 38 + ''; 39 + in '' 22 40 mkdir -p $out/bin 23 41 mkdir -p $out/share/arabica 24 42 ··· 26 44 cp -r web $out/share/arabica/ 27 45 cp -r templates $out/share/arabica/ 28 46 cp arabica $out/bin/arabica-unwrapped 29 - cat > $out/bin/arabica <<'EOF' 30 - #!/bin/sh 31 - SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" 32 - SHARE_DIR="$SCRIPT_DIR/../share/arabica" 33 - cd "$SHARE_DIR" 34 - exec "$SCRIPT_DIR/arabica-unwrapped" "$@" 35 - EOF 47 + cat > $out/bin/arabica <<'WRAPPER' 48 + ${wrapperScript} 49 + WRAPPER 36 50 chmod +x $out/bin/arabica 37 51 ''; 38 52
+1
go.mod
··· 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20260103083015-78a1c1894f36 7 7 github.com/rs/zerolog v1.34.0 8 + go.etcd.io/bbolt v1.3.8 8 9 ) 9 10 10 11 require (
+2
go.sum
··· 69 69 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 70 70 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 71 71 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 72 + go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA= 73 + go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 72 74 golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 73 75 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 74 76 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+8 -7
internal/atproto/oauth.go
··· 26 26 onAuthSuccess func(did string) // Callback when user authenticates successfully 27 27 } 28 28 29 - // NewOAuthManager creates a new OAuth manager with the given configuration 30 - func NewOAuthManager(clientID, redirectURI string) (*OAuthManager, error) { 29 + // NewOAuthManager creates a new OAuth manager with the given configuration. 30 + // If store is nil, an in-memory store will be used (sessions lost on restart). 31 + // For production, pass a persistent store (e.g., boltstore.SessionStore). 32 + func NewOAuthManager(clientID, redirectURI string, store oauth.ClientAuthStore) (*OAuthManager, error) { 31 33 var config oauth.ClientConfig 32 34 33 35 // Check if we should use localhost config ··· 39 41 config = oauth.NewPublicConfig(clientID, redirectURI, scopes) 40 42 } 41 43 42 - // Use in-memory store for development 43 - // TODO(production): Replace with persistent store (e.g., SQLite-backed oauth.Store implementation) 44 - // The in-memory store will lose all sessions on server restart, requiring users to re-authenticate. 45 - // For production, implement oauth.Store interface with persistent storage. 46 - store := oauth.NewMemStore() 44 + // Use provided store, or fall back to in-memory for development 45 + if store == nil { 46 + store = oauth.NewMemStore() 47 + } 47 48 48 49 // Create the OAuth client app 49 50 app := oauth.NewClientApp(&config, store)
+157
internal/database/boltstore/feed_store.go
··· 1 + package boltstore 2 + 3 + import ( 4 + "encoding/json" 5 + "time" 6 + 7 + bolt "go.etcd.io/bbolt" 8 + ) 9 + 10 + // FeedUser represents a registered user in the feed registry. 11 + type FeedUser struct { 12 + DID string `json:"did"` 13 + RegisteredAt time.Time `json:"registered_at"` 14 + } 15 + 16 + // FeedStore provides persistent storage for the feed registry. 17 + // It stores DIDs of users who have logged in and should appear in the community feed. 18 + type FeedStore struct { 19 + db *bolt.DB 20 + } 21 + 22 + // Register adds a DID to the feed registry. 23 + // If the DID already exists, this is a no-op. 24 + func (s *FeedStore) Register(did string) error { 25 + return s.db.Update(func(tx *bolt.Tx) error { 26 + bucket := tx.Bucket(BucketFeedRegistry) 27 + if bucket == nil { 28 + return nil 29 + } 30 + 31 + // Check if already registered 32 + existing := bucket.Get([]byte(did)) 33 + if existing != nil { 34 + // Already registered, no-op 35 + return nil 36 + } 37 + 38 + // Create new registration 39 + user := FeedUser{ 40 + DID: did, 41 + RegisteredAt: time.Now(), 42 + } 43 + 44 + data, err := json.Marshal(user) 45 + if err != nil { 46 + return err 47 + } 48 + 49 + return bucket.Put([]byte(did), data) 50 + }) 51 + } 52 + 53 + // Unregister removes a DID from the feed registry. 54 + func (s *FeedStore) Unregister(did string) error { 55 + return s.db.Update(func(tx *bolt.Tx) error { 56 + bucket := tx.Bucket(BucketFeedRegistry) 57 + if bucket == nil { 58 + return nil 59 + } 60 + 61 + return bucket.Delete([]byte(did)) 62 + }) 63 + } 64 + 65 + // IsRegistered checks if a DID is in the feed registry. 66 + func (s *FeedStore) IsRegistered(did string) bool { 67 + var registered bool 68 + 69 + s.db.View(func(tx *bolt.Tx) error { 70 + bucket := tx.Bucket(BucketFeedRegistry) 71 + if bucket == nil { 72 + return nil 73 + } 74 + 75 + registered = bucket.Get([]byte(did)) != nil 76 + return nil 77 + }) 78 + 79 + return registered 80 + } 81 + 82 + // List returns all registered DIDs. 83 + func (s *FeedStore) List() []string { 84 + var dids []string 85 + 86 + s.db.View(func(tx *bolt.Tx) error { 87 + bucket := tx.Bucket(BucketFeedRegistry) 88 + if bucket == nil { 89 + return nil 90 + } 91 + 92 + return bucket.ForEach(func(k, v []byte) error { 93 + dids = append(dids, string(k)) 94 + return nil 95 + }) 96 + }) 97 + 98 + return dids 99 + } 100 + 101 + // ListWithMetadata returns all registered users with their metadata. 102 + func (s *FeedStore) ListWithMetadata() []FeedUser { 103 + var users []FeedUser 104 + 105 + s.db.View(func(tx *bolt.Tx) error { 106 + bucket := tx.Bucket(BucketFeedRegistry) 107 + if bucket == nil { 108 + return nil 109 + } 110 + 111 + return bucket.ForEach(func(k, v []byte) error { 112 + var user FeedUser 113 + if err := json.Unmarshal(v, &user); err != nil { 114 + // Fallback for simple keys without metadata 115 + user = FeedUser{DID: string(k)} 116 + } 117 + users = append(users, user) 118 + return nil 119 + }) 120 + }) 121 + 122 + return users 123 + } 124 + 125 + // Count returns the number of registered users. 126 + func (s *FeedStore) Count() int { 127 + var count int 128 + 129 + s.db.View(func(tx *bolt.Tx) error { 130 + bucket := tx.Bucket(BucketFeedRegistry) 131 + if bucket == nil { 132 + return nil 133 + } 134 + 135 + count = bucket.Stats().KeyN 136 + return nil 137 + }) 138 + 139 + return count 140 + } 141 + 142 + // Clear removes all entries from the feed registry. 143 + // Use with caution - primarily for testing. 144 + func (s *FeedStore) Clear() error { 145 + return s.db.Update(func(tx *bolt.Tx) error { 146 + // Delete and recreate the bucket 147 + if err := tx.DeleteBucket(BucketFeedRegistry); err != nil { 148 + // Bucket might not exist, that's ok 149 + if err != bolt.ErrBucketNotFound { 150 + return err 151 + } 152 + } 153 + 154 + _, err := tx.CreateBucket(BucketFeedRegistry) 155 + return err 156 + }) 157 + }
+207
internal/database/boltstore/session_store.go
··· 1 + package boltstore 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + 8 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + bolt "go.etcd.io/bbolt" 11 + ) 12 + 13 + // SessionStore implements oauth.ClientAuthStore using BoltDB for persistence. 14 + // It stores OAuth sessions and auth request data, allowing sessions to survive 15 + // server restarts. 16 + type SessionStore struct { 17 + db *bolt.DB 18 + } 19 + 20 + // Ensure SessionStore implements oauth.ClientAuthStore 21 + var _ oauth.ClientAuthStore = (*SessionStore)(nil) 22 + 23 + // sessionKey generates a composite key for session storage: "did:sessionID" 24 + func sessionKey(did syntax.DID, sessionID string) []byte { 25 + return []byte(did.String() + ":" + sessionID) 26 + } 27 + 28 + // GetSession retrieves a session by DID and session ID. 29 + // Returns an error if the session is not found. 30 + func (s *SessionStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 31 + var session oauth.ClientSessionData 32 + 33 + err := s.db.View(func(tx *bolt.Tx) error { 34 + bucket := tx.Bucket(BucketSessions) 35 + if bucket == nil { 36 + return fmt.Errorf("session bucket not found") 37 + } 38 + 39 + data := bucket.Get(sessionKey(did, sessionID)) 40 + if data == nil { 41 + return fmt.Errorf("session not found") 42 + } 43 + 44 + return json.Unmarshal(data, &session) 45 + }) 46 + 47 + if err != nil { 48 + return nil, err 49 + } 50 + 51 + return &session, nil 52 + } 53 + 54 + // SaveSession persists a session (upsert operation). 55 + // If a session with the same DID and sessionID exists, it will be updated. 56 + func (s *SessionStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 57 + data, err := json.Marshal(sess) 58 + if err != nil { 59 + return fmt.Errorf("failed to marshal session: %w", err) 60 + } 61 + 62 + return s.db.Update(func(tx *bolt.Tx) error { 63 + bucket := tx.Bucket(BucketSessions) 64 + if bucket == nil { 65 + return fmt.Errorf("session bucket not found") 66 + } 67 + 68 + return bucket.Put(sessionKey(sess.AccountDID, sess.SessionID), data) 69 + }) 70 + } 71 + 72 + // DeleteSession removes a session by DID and session ID. 73 + func (s *SessionStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 74 + return s.db.Update(func(tx *bolt.Tx) error { 75 + bucket := tx.Bucket(BucketSessions) 76 + if bucket == nil { 77 + return fmt.Errorf("session bucket not found") 78 + } 79 + 80 + return bucket.Delete(sessionKey(did, sessionID)) 81 + }) 82 + } 83 + 84 + // GetAuthRequestInfo retrieves pending auth request data by state token. 85 + func (s *SessionStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 86 + var info oauth.AuthRequestData 87 + 88 + err := s.db.View(func(tx *bolt.Tx) error { 89 + bucket := tx.Bucket(BucketAuthRequests) 90 + if bucket == nil { 91 + return fmt.Errorf("auth requests bucket not found") 92 + } 93 + 94 + data := bucket.Get([]byte(state)) 95 + if data == nil { 96 + return fmt.Errorf("auth request not found") 97 + } 98 + 99 + return json.Unmarshal(data, &info) 100 + }) 101 + 102 + if err != nil { 103 + return nil, err 104 + } 105 + 106 + return &info, nil 107 + } 108 + 109 + // SaveAuthRequestInfo stores auth request data keyed by state token. 110 + // This is a create-only operation per the oauth.ClientAuthStore contract. 111 + func (s *SessionStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 112 + data, err := json.Marshal(info) 113 + if err != nil { 114 + return fmt.Errorf("failed to marshal auth request: %w", err) 115 + } 116 + 117 + return s.db.Update(func(tx *bolt.Tx) error { 118 + bucket := tx.Bucket(BucketAuthRequests) 119 + if bucket == nil { 120 + return fmt.Errorf("auth requests bucket not found") 121 + } 122 + 123 + return bucket.Put([]byte(info.State), data) 124 + }) 125 + } 126 + 127 + // DeleteAuthRequestInfo removes auth request data by state token. 128 + func (s *SessionStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 129 + return s.db.Update(func(tx *bolt.Tx) error { 130 + bucket := tx.Bucket(BucketAuthRequests) 131 + if bucket == nil { 132 + return fmt.Errorf("auth requests bucket not found") 133 + } 134 + 135 + return bucket.Delete([]byte(state)) 136 + }) 137 + } 138 + 139 + // ListSessions returns all sessions (for debugging/admin purposes). 140 + func (s *SessionStore) ListSessions(ctx context.Context) ([]oauth.ClientSessionData, error) { 141 + var sessions []oauth.ClientSessionData 142 + 143 + err := s.db.View(func(tx *bolt.Tx) error { 144 + bucket := tx.Bucket(BucketSessions) 145 + if bucket == nil { 146 + return nil 147 + } 148 + 149 + return bucket.ForEach(func(k, v []byte) error { 150 + var session oauth.ClientSessionData 151 + if err := json.Unmarshal(v, &session); err != nil { 152 + // Skip malformed entries 153 + return nil 154 + } 155 + sessions = append(sessions, session) 156 + return nil 157 + }) 158 + }) 159 + 160 + return sessions, err 161 + } 162 + 163 + // CountSessions returns the number of stored sessions. 164 + func (s *SessionStore) CountSessions(ctx context.Context) (int, error) { 165 + var count int 166 + 167 + err := s.db.View(func(tx *bolt.Tx) error { 168 + bucket := tx.Bucket(BucketSessions) 169 + if bucket == nil { 170 + return nil 171 + } 172 + 173 + count = bucket.Stats().KeyN 174 + return nil 175 + }) 176 + 177 + return count, err 178 + } 179 + 180 + // DeleteAllSessionsForDID removes all sessions for a given DID. 181 + // Useful for "logout from all devices" functionality. 182 + func (s *SessionStore) DeleteAllSessionsForDID(ctx context.Context, did syntax.DID) error { 183 + prefix := []byte(did.String() + ":") 184 + 185 + return s.db.Update(func(tx *bolt.Tx) error { 186 + bucket := tx.Bucket(BucketSessions) 187 + if bucket == nil { 188 + return nil 189 + } 190 + 191 + // Collect keys to delete (can't delete while iterating) 192 + var keysToDelete [][]byte 193 + c := bucket.Cursor() 194 + for k, _ := c.Seek(prefix); k != nil && len(k) >= len(prefix) && string(k[:len(prefix)]) == string(prefix); k, _ = c.Next() { 195 + keysToDelete = append(keysToDelete, append([]byte{}, k...)) 196 + } 197 + 198 + // Delete collected keys 199 + for _, k := range keysToDelete { 200 + if err := bucket.Delete(k); err != nil { 201 + return err 202 + } 203 + } 204 + 205 + return nil 206 + }) 207 + }
+135
internal/database/boltstore/store.go
··· 1 + // Package boltstore provides persistent storage using BoltDB (bbolt). 2 + // It implements the oauth.ClientAuthStore interface for session persistence 3 + // and provides storage for the feed registry. 4 + package boltstore 5 + 6 + import ( 7 + "fmt" 8 + "os" 9 + "path/filepath" 10 + "time" 11 + 12 + bolt "go.etcd.io/bbolt" 13 + ) 14 + 15 + // Bucket names for organizing data 16 + var ( 17 + // BucketSessions stores OAuth session data keyed by "did:sessionID" 18 + BucketSessions = []byte("oauth_sessions") 19 + 20 + // BucketAuthRequests stores pending OAuth auth requests keyed by state 21 + BucketAuthRequests = []byte("oauth_auth_requests") 22 + 23 + // BucketFeedRegistry stores registered user DIDs for the community feed 24 + BucketFeedRegistry = []byte("feed_registry") 25 + ) 26 + 27 + // Store wraps a BoltDB database and provides access to specialized stores. 28 + type Store struct { 29 + db *bolt.DB 30 + } 31 + 32 + // Options configures the BoltDB store. 33 + type Options struct { 34 + // Path to the database file. Parent directories will be created if needed. 35 + Path string 36 + 37 + // Timeout for obtaining a file lock on the database. 38 + // If zero, a default of 5 seconds is used. 39 + Timeout time.Duration 40 + 41 + // FileMode for creating the database file. 42 + // If zero, 0600 is used. 43 + FileMode os.FileMode 44 + } 45 + 46 + // DefaultOptions returns sensible defaults for development. 47 + func DefaultOptions() Options { 48 + return Options{ 49 + Path: "arabica.db", 50 + Timeout: 5 * time.Second, 51 + FileMode: 0600, 52 + } 53 + } 54 + 55 + // Open creates or opens a BoltDB database at the specified path. 56 + // It creates all necessary buckets if they don't exist. 57 + func Open(opts Options) (*Store, error) { 58 + if opts.Path == "" { 59 + opts.Path = "arabica.db" 60 + } 61 + if opts.Timeout == 0 { 62 + opts.Timeout = 5 * time.Second 63 + } 64 + if opts.FileMode == 0 { 65 + opts.FileMode = 0600 66 + } 67 + 68 + // Ensure parent directory exists 69 + dir := filepath.Dir(opts.Path) 70 + if dir != "" && dir != "." { 71 + if err := os.MkdirAll(dir, 0755); err != nil { 72 + return nil, fmt.Errorf("failed to create database directory: %w", err) 73 + } 74 + } 75 + 76 + // Open the database 77 + db, err := bolt.Open(opts.Path, opts.FileMode, &bolt.Options{ 78 + Timeout: opts.Timeout, 79 + }) 80 + if err != nil { 81 + return nil, fmt.Errorf("failed to open database: %w", err) 82 + } 83 + 84 + // Create buckets if they don't exist 85 + err = db.Update(func(tx *bolt.Tx) error { 86 + buckets := [][]byte{ 87 + BucketSessions, 88 + BucketAuthRequests, 89 + BucketFeedRegistry, 90 + } 91 + 92 + for _, bucket := range buckets { 93 + _, err := tx.CreateBucketIfNotExists(bucket) 94 + if err != nil { 95 + return fmt.Errorf("failed to create bucket %s: %w", bucket, err) 96 + } 97 + } 98 + 99 + return nil 100 + }) 101 + if err != nil { 102 + db.Close() 103 + return nil, err 104 + } 105 + 106 + return &Store{db: db}, nil 107 + } 108 + 109 + // Close closes the database. 110 + func (s *Store) Close() error { 111 + if s.db != nil { 112 + return s.db.Close() 113 + } 114 + return nil 115 + } 116 + 117 + // DB returns the underlying BoltDB instance for advanced operations. 118 + func (s *Store) DB() *bolt.DB { 119 + return s.db 120 + } 121 + 122 + // SessionStore returns an OAuth session store backed by this database. 123 + func (s *Store) SessionStore() *SessionStore { 124 + return &SessionStore{db: s.db} 125 + } 126 + 127 + // FeedStore returns a feed registry store backed by this database. 128 + func (s *Store) FeedStore() *FeedStore { 129 + return &FeedStore{db: s.db} 130 + } 131 + 132 + // Stats returns database statistics. 133 + func (s *Store) Stats() bolt.Stats { 134 + return s.db.Stats() 135 + }
+55 -10
internal/feed/registry.go
··· 4 4 "sync" 5 5 ) 6 6 7 + // PersistentStore defines the interface for persistent feed registry storage. 8 + // This allows the Registry to optionally persist DIDs to a database. 9 + type PersistentStore interface { 10 + Register(did string) error 11 + Unregister(did string) error 12 + IsRegistered(did string) bool 13 + List() []string 14 + Count() int 15 + } 16 + 7 17 // Registry tracks known Arabica users (DIDs) for the social feed. 8 - // This is an in-memory store that gets populated as users log in. 9 - // In the future, this could be persisted to a database. 18 + // It maintains an in-memory cache for fast access and optionally 19 + // persists data to a backing store. 10 20 type Registry struct { 11 - mu sync.RWMutex 12 - dids map[string]struct{} 21 + mu sync.RWMutex 22 + dids map[string]struct{} 23 + store PersistentStore // Optional persistent backing store 13 24 } 14 25 15 - // NewRegistry creates a new user registry 26 + // NewRegistry creates a new user registry with in-memory storage only. 16 27 func NewRegistry() *Registry { 17 28 return &Registry{ 18 29 dids: make(map[string]struct{}), 19 30 } 20 31 } 21 32 22 - // Register adds a DID to the registry 33 + // NewPersistentRegistry creates a new registry backed by persistent storage. 34 + // It loads existing registrations from the store on creation. 35 + func NewPersistentRegistry(store PersistentStore) *Registry { 36 + r := &Registry{ 37 + dids: make(map[string]struct{}), 38 + store: store, 39 + } 40 + 41 + // Load existing registrations from store 42 + if store != nil { 43 + for _, did := range store.List() { 44 + r.dids[did] = struct{}{} 45 + } 46 + } 47 + 48 + return r 49 + } 50 + 51 + // Register adds a DID to the registry. 52 + // If a persistent store is configured, the DID is also saved to the store. 23 53 func (r *Registry) Register(did string) { 24 54 r.mu.Lock() 25 55 defer r.mu.Unlock() 56 + 57 + // Add to in-memory cache 26 58 r.dids[did] = struct{}{} 59 + 60 + // Persist if store is configured 61 + if r.store != nil { 62 + // Ignore errors for now - the in-memory cache is the source of truth 63 + // during this session, and we'll retry on next restart 64 + _ = r.store.Register(did) 65 + } 27 66 } 28 67 29 - // Unregister removes a DID from the registry 68 + // Unregister removes a DID from the registry. 69 + // If a persistent store is configured, the DID is also removed from the store. 30 70 func (r *Registry) Unregister(did string) { 31 71 r.mu.Lock() 32 72 defer r.mu.Unlock() 73 + 33 74 delete(r.dids, did) 75 + 76 + if r.store != nil { 77 + _ = r.store.Unregister(did) 78 + } 34 79 } 35 80 36 - // IsRegistered checks if a DID is in the registry 81 + // IsRegistered checks if a DID is in the registry. 37 82 func (r *Registry) IsRegistered(did string) bool { 38 83 r.mu.RLock() 39 84 defer r.mu.RUnlock() ··· 41 86 return ok 42 87 } 43 88 44 - // List returns all registered DIDs 89 + // List returns all registered DIDs. 45 90 func (r *Registry) List() []string { 46 91 r.mu.RLock() 47 92 defer r.mu.RUnlock() ··· 53 98 return dids 54 99 } 55 100 56 - // Count returns the number of registered users 101 + // Count returns the number of registered users. 57 102 func (r *Registry) Count() int { 58 103 r.mu.RLock() 59 104 defer r.mu.RUnlock()
+1
module.nix
··· 133 133 SECURE_COOKIES = lib.boolToString cfg.settings.secureCookies; 134 134 OAUTH_CLIENT_ID = cfg.oauth.clientId; 135 135 OAUTH_REDIRECT_URI = cfg.oauth.redirectUri; 136 + ARABICA_DB_PATH = "${cfg.dataDir}/arabica.db"; 136 137 }; 137 138 }; 138 139