An open source supporter broker powered by high-fives.
high-five.atprotofans.com/
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}