An open source supporter broker powered by high-fives. high-five.atprotofans.com/
at main 359 lines 11 kB view raw
1package storage 2 3import ( 4 "context" 5 "crypto/ecdsa" 6 "crypto/elliptic" 7 "crypto/rand" 8 "crypto/x509" 9 "encoding/json" 10 "encoding/pem" 11 "fmt" 12 "time" 13 14 "github.com/redis/go-redis/v9" 15) 16 17// Store provides Redis-based storage for sessions, tokens, and high-five state. 18type Store struct { 19 client *redis.Client 20} 21 22// NewStore creates a new Redis store. 23func NewStore(redisURL string) (*Store, error) { 24 opts, err := redis.ParseURL(redisURL) 25 if err != nil { 26 return nil, fmt.Errorf("invalid redis URL: %w", err) 27 } 28 29 client := redis.NewClient(opts) 30 31 // Test connection 32 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 33 defer cancel() 34 35 if err := client.Ping(ctx).Err(); err != nil { 36 return nil, fmt.Errorf("failed to connect to redis: %w", err) 37 } 38 39 return &Store{client: client}, nil 40} 41 42// Close closes the Redis connection. 43func (s *Store) Close() error { 44 return s.client.Close() 45} 46 47// Session represents a user's web session. 48type Session struct { 49 ID string `json:"id"` 50 DID string `json:"did"` 51 Handle string `json:"handle"` 52 PDSURL string `json:"pds_url"` 53 AccessToken string `json:"access_token"` 54 RefreshToken string `json:"refresh_token"` 55 TokenExpiry time.Time `json:"token_expiry"` 56 DPoPKeyPEM string `json:"dpop_key_pem"` 57 CreatedAt time.Time `json:"created_at"` 58} 59 60// OAuthState represents the state during OAuth flow. 61type OAuthState struct { 62 State string `json:"state"` 63 CodeVerifier string `json:"code_verifier"` 64 DPoPKeyPEM string `json:"dpop_key_pem"` 65 PDSURL string `json:"pds_url"` 66 AuthServer string `json:"auth_server"` 67 Handle string `json:"handle"` 68 CreatedAt time.Time `json:"created_at"` 69} 70 71// BrokerSession represents the broker's ATProtocol session. 72type BrokerSession struct { 73 DID string `json:"did"` 74 Handle string `json:"handle"` 75 PDSURL string `json:"pds_url"` 76 AccessJWT string `json:"access_jwt"` 77 RefreshJWT string `json:"refresh_jwt"` 78 TokenExpiry time.Time `json:"token_expiry"` 79 CreatedAt time.Time `json:"created_at"` 80} 81 82// UserPreferences stores user settings for the high-five room. 83type UserPreferences struct { 84 CreateAnnouncementPost bool `json:"create_announcement_post"` 85 CreateHighFivePost bool `json:"create_high_five_post"` 86} 87 88const ( 89 sessionPrefix = "session:" 90 oauthStatePrefix = "oauth_state:" 91 dpopNoncePrefix = "dpop_nonce:" 92 brokerSessionKey = "broker_session" 93 announcementPrefix = "announced:" // {did} → timestamp 94 highFiveAnnouncedPrefix = "high-five-announced:" // {did} → timestamp 95 highFivePairPrefix = "high-five-pair:" // {subject}:{from_did} → timestamp 96 userPrefsPrefix = "user_prefs:" // {did} → UserPreferences JSON 97 98 sessionTTL = 24 * time.Hour 99 oauthStateTTL = 20 * time.Minute 100 brokerSessionTTL = 12 * time.Hour 101 announcementTTL = 30 * time.Minute 102 highFiveAnnouncedTTL = 30 * time.Minute 103 highFivePairTTL = 60 * time.Minute 104 userPrefsTTL = 24 * time.Hour 105) 106 107// SaveSession stores a session in Redis. 108func (s *Store) SaveSession(ctx context.Context, session *Session) error { 109 data, err := json.Marshal(session) 110 if err != nil { 111 return fmt.Errorf("failed to marshal session: %w", err) 112 } 113 114 key := sessionPrefix + session.ID 115 if err := s.client.Set(ctx, key, data, sessionTTL).Err(); err != nil { 116 return fmt.Errorf("failed to save session: %w", err) 117 } 118 119 return nil 120} 121 122// GetSession retrieves a session from Redis. 123func (s *Store) GetSession(ctx context.Context, sessionID string) (*Session, error) { 124 key := sessionPrefix + sessionID 125 data, err := s.client.Get(ctx, key).Bytes() 126 if err != nil { 127 if err == redis.Nil { 128 return nil, nil 129 } 130 return nil, fmt.Errorf("failed to get session: %w", err) 131 } 132 133 var session Session 134 if err := json.Unmarshal(data, &session); err != nil { 135 return nil, fmt.Errorf("failed to unmarshal session: %w", err) 136 } 137 138 return &session, nil 139} 140 141// DeleteSession removes a session from Redis. 142func (s *Store) DeleteSession(ctx context.Context, sessionID string) error { 143 key := sessionPrefix + sessionID 144 return s.client.Del(ctx, key).Err() 145} 146 147// SaveOAuthState stores OAuth state during the authorization flow. 148func (s *Store) SaveOAuthState(ctx context.Context, state *OAuthState) error { 149 data, err := json.Marshal(state) 150 if err != nil { 151 return fmt.Errorf("failed to marshal oauth state: %w", err) 152 } 153 154 key := oauthStatePrefix + state.State 155 if err := s.client.Set(ctx, key, data, oauthStateTTL).Err(); err != nil { 156 return fmt.Errorf("failed to save oauth state: %w", err) 157 } 158 159 return nil 160} 161 162// GetOAuthState retrieves OAuth state from Redis. 163func (s *Store) GetOAuthState(ctx context.Context, state string) (*OAuthState, error) { 164 key := oauthStatePrefix + state 165 data, err := s.client.Get(ctx, key).Bytes() 166 if err != nil { 167 if err == redis.Nil { 168 return nil, nil 169 } 170 return nil, fmt.Errorf("failed to get oauth state: %w", err) 171 } 172 173 var oauthState OAuthState 174 if err := json.Unmarshal(data, &oauthState); err != nil { 175 return nil, fmt.Errorf("failed to unmarshal oauth state: %w", err) 176 } 177 178 return &oauthState, nil 179} 180 181// DeleteOAuthState removes OAuth state from Redis. 182func (s *Store) DeleteOAuthState(ctx context.Context, state string) error { 183 key := oauthStatePrefix + state 184 return s.client.Del(ctx, key).Err() 185} 186 187// SaveDPoPNonce stores a DPoP nonce for a server. 188func (s *Store) SaveDPoPNonce(ctx context.Context, server, nonce string) error { 189 key := dpopNoncePrefix + server 190 return s.client.Set(ctx, key, nonce, 5*time.Minute).Err() 191} 192 193// GetDPoPNonce retrieves a DPoP nonce for a server. 194func (s *Store) GetDPoPNonce(ctx context.Context, server string) (string, error) { 195 key := dpopNoncePrefix + server 196 nonce, err := s.client.Get(ctx, key).Result() 197 if err != nil { 198 if err == redis.Nil { 199 return "", nil 200 } 201 return "", err 202 } 203 return nonce, nil 204} 205 206// GenerateECDSAKey generates a new ECDSA P-256 key pair and returns PEM-encoded private key. 207func GenerateECDSAKey() (string, error) { 208 privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 209 if err != nil { 210 return "", fmt.Errorf("failed to generate key: %w", err) 211 } 212 213 keyBytes, err := x509.MarshalECPrivateKey(privateKey) 214 if err != nil { 215 return "", fmt.Errorf("failed to marshal key: %w", err) 216 } 217 218 pemBlock := &pem.Block{ 219 Type: "EC PRIVATE KEY", 220 Bytes: keyBytes, 221 } 222 223 return string(pem.EncodeToMemory(pemBlock)), nil 224} 225 226// ParseECDSAKey parses a PEM-encoded ECDSA private key. 227func ParseECDSAKey(pemKey string) (*ecdsa.PrivateKey, error) { 228 block, _ := pem.Decode([]byte(pemKey)) 229 if block == nil { 230 return nil, fmt.Errorf("failed to decode PEM block") 231 } 232 233 return x509.ParseECPrivateKey(block.Bytes) 234} 235 236// SaveBrokerSession stores the broker's session in Redis. 237func (s *Store) SaveBrokerSession(ctx context.Context, session *BrokerSession) error { 238 data, err := json.Marshal(session) 239 if err != nil { 240 return fmt.Errorf("failed to marshal broker session: %w", err) 241 } 242 243 if err := s.client.Set(ctx, brokerSessionKey, data, brokerSessionTTL).Err(); err != nil { 244 return fmt.Errorf("failed to save broker session: %w", err) 245 } 246 247 return nil 248} 249 250// GetBrokerSession retrieves the broker's session from Redis. 251func (s *Store) GetBrokerSession(ctx context.Context) (*BrokerSession, error) { 252 data, err := s.client.Get(ctx, brokerSessionKey).Bytes() 253 if err != nil { 254 if err == redis.Nil { 255 return nil, nil 256 } 257 return nil, fmt.Errorf("failed to get broker session: %w", err) 258 } 259 260 var session BrokerSession 261 if err := json.Unmarshal(data, &session); err != nil { 262 return nil, fmt.Errorf("failed to unmarshal broker session: %w", err) 263 } 264 265 return &session, nil 266} 267 268// DeleteBrokerSession removes the broker's session from Redis. 269func (s *Store) DeleteBrokerSession(ctx context.Context) error { 270 return s.client.Del(ctx, brokerSessionKey).Err() 271} 272 273// CheckAnnouncementRateLimit checks if a user is rate limited from creating announcement posts. 274func (s *Store) CheckAnnouncementRateLimit(ctx context.Context, did string) (bool, error) { 275 key := announcementPrefix + did 276 exists, err := s.client.Exists(ctx, key).Result() 277 if err != nil { 278 return false, fmt.Errorf("failed to check announcement rate limit: %w", err) 279 } 280 return exists > 0, nil 281} 282 283// SetAnnouncementRateLimit sets a rate limit for announcement post creation. 284func (s *Store) SetAnnouncementRateLimit(ctx context.Context, did string) error { 285 key := announcementPrefix + did 286 return s.client.Set(ctx, key, "1", announcementTTL).Err() 287} 288 289// CheckHighFiveAnnouncementRateLimit checks if a user is rate limited from creating high-five announcement posts. 290func (s *Store) CheckHighFiveAnnouncementRateLimit(ctx context.Context, did string) (bool, error) { 291 key := highFiveAnnouncedPrefix + did 292 exists, err := s.client.Exists(ctx, key).Result() 293 if err != nil { 294 return false, fmt.Errorf("failed to check high-five announcement rate limit: %w", err) 295 } 296 return exists > 0, nil 297} 298 299// SetHighFiveAnnouncementRateLimit sets a rate limit for high-five announcement post creation. 300func (s *Store) SetHighFiveAnnouncementRateLimit(ctx context.Context, did string) error { 301 key := highFiveAnnouncedPrefix + did 302 return s.client.Set(ctx, key, "1", highFiveAnnouncedTTL).Err() 303} 304 305// CheckHighFivePairRateLimit checks if a high-five from one user to another is rate limited. 306// This is directional: A→B and B→A are tracked separately. 307func (s *Store) CheckHighFivePairRateLimit(ctx context.Context, subjectDID, fromDID string) (bool, error) { 308 key := highFivePairPrefix + subjectDID + ":" + fromDID 309 exists, err := s.client.Exists(ctx, key).Result() 310 if err != nil { 311 return false, fmt.Errorf("failed to check high-five pair rate limit: %w", err) 312 } 313 return exists > 0, nil 314} 315 316// SetHighFivePairRateLimit sets the rate limit for high-fives from one user to another. 317// This is directional: A→B and B→A are tracked separately. 318func (s *Store) SetHighFivePairRateLimit(ctx context.Context, subjectDID, fromDID string) error { 319 key := highFivePairPrefix + subjectDID + ":" + fromDID 320 return s.client.Set(ctx, key, "1", highFivePairTTL).Err() 321} 322 323// SaveUserPreferences stores user preferences in Redis. 324func (s *Store) SaveUserPreferences(ctx context.Context, did string, prefs *UserPreferences) error { 325 data, err := json.Marshal(prefs) 326 if err != nil { 327 return fmt.Errorf("failed to marshal user preferences: %w", err) 328 } 329 330 key := userPrefsPrefix + did 331 if err := s.client.Set(ctx, key, data, userPrefsTTL).Err(); err != nil { 332 return fmt.Errorf("failed to save user preferences: %w", err) 333 } 334 335 return nil 336} 337 338// GetUserPreferences retrieves user preferences from Redis. 339func (s *Store) GetUserPreferences(ctx context.Context, did string) (*UserPreferences, error) { 340 key := userPrefsPrefix + did 341 data, err := s.client.Get(ctx, key).Bytes() 342 if err != nil { 343 if err == redis.Nil { 344 // Return default preferences if not set 345 return &UserPreferences{ 346 CreateAnnouncementPost: false, 347 CreateHighFivePost: false, 348 }, nil 349 } 350 return nil, fmt.Errorf("failed to get user preferences: %w", err) 351 } 352 353 var prefs UserPreferences 354 if err := json.Unmarshal(data, &prefs); err != nil { 355 return nil, fmt.Errorf("failed to unmarshal user preferences: %w", err) 356 } 357 358 return &prefs, nil 359}