A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
1package db
2
3import (
4 "context"
5 "crypto/rand"
6 "database/sql"
7 "encoding/base64"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "time"
12)
13
14// Session represents a user session
15// Compatible with pkg/appview/session.Session
16type Session struct {
17 ID string
18 DID string
19 Handle string
20 PDSEndpoint string
21 OAuthSessionID string // Links to oauth_sessions.session_id
22 ExpiresAt time.Time
23}
24
25// SessionStoreInterface defines the session storage interface
26// Both db.SessionStore and session.Store implement this
27type SessionStoreInterface interface {
28 Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
29 CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
30 Get(id string) (*Session, bool)
31 Delete(id string)
32 Cleanup()
33}
34
35// SessionStore manages user sessions with SQLite persistence
36type SessionStore struct {
37 db *sql.DB
38}
39
40// NewSessionStore creates a new SQLite-backed session store
41func NewSessionStore(db *sql.DB) *SessionStore {
42 return &SessionStore{db: db}
43}
44
45// Create creates a new session and returns the session ID
46func (s *SessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
47 return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration)
48}
49
50// CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID
51func (s *SessionStore) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) {
52 // Generate random session ID
53 b := make([]byte, 32)
54 if _, err := rand.Read(b); err != nil {
55 return "", fmt.Errorf("failed to generate session ID: %w", err)
56 }
57
58 sessionID := base64.URLEncoding.EncodeToString(b)
59 expiresAt := time.Now().Add(duration)
60
61 _, err := s.db.Exec(`
62 INSERT INTO ui_sessions (id, did, handle, pds_endpoint, oauth_session_id, expires_at, created_at)
63 VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
64 `, sessionID, did, handle, pdsEndpoint, oauthSessionID, expiresAt)
65
66 if err != nil {
67 return "", fmt.Errorf("failed to create session: %w", err)
68 }
69
70 return sessionID, nil
71}
72
73// Get retrieves a session by ID
74func (s *SessionStore) Get(id string) (*Session, bool) {
75 var sess Session
76
77 err := s.db.QueryRow(`
78 SELECT id, did, handle, pds_endpoint, oauth_session_id, expires_at
79 FROM ui_sessions
80 WHERE id = ?
81 `, id).Scan(&sess.ID, &sess.DID, &sess.Handle, &sess.PDSEndpoint, &sess.OAuthSessionID, &sess.ExpiresAt)
82
83 if err == sql.ErrNoRows {
84 return nil, false
85 }
86 if err != nil {
87 slog.Warn("Failed to query session", "error", err)
88 return nil, false
89 }
90
91 // Check if expired
92 if time.Now().After(sess.ExpiresAt) {
93 return nil, false
94 }
95
96 return &sess, true
97}
98
99// Extend extends a session's expiration time
100func (s *SessionStore) Extend(id string, duration time.Duration) error {
101 expiresAt := time.Now().Add(duration)
102
103 result, err := s.db.Exec(`
104 UPDATE ui_sessions
105 SET expires_at = ?
106 WHERE id = ?
107 `, expiresAt, id)
108
109 if err != nil {
110 return fmt.Errorf("failed to extend session: %w", err)
111 }
112
113 rows, _ := result.RowsAffected()
114 if rows == 0 {
115 return fmt.Errorf("session not found: %s", id)
116 }
117
118 return nil
119}
120
121// Delete removes a session
122func (s *SessionStore) Delete(id string) {
123 _, err := s.db.Exec(`
124 DELETE FROM ui_sessions WHERE id = ?
125 `, id)
126
127 if err != nil {
128 slog.Warn("Failed to delete session", "error", err)
129 }
130}
131
132// DeleteByDID removes all sessions for a given DID
133// This is useful when OAuth refresh fails and we need to force re-authentication
134func (s *SessionStore) DeleteByDID(did string) {
135 result, err := s.db.Exec(`
136 DELETE FROM ui_sessions WHERE did = ?
137 `, did)
138
139 if err != nil {
140 slog.Warn("Failed to delete sessions for DID", "did", did, "error", err)
141 return
142 }
143
144 deleted, _ := result.RowsAffected()
145 if deleted > 0 {
146 slog.Info("Deleted UI sessions for DID due to OAuth failure", "count", deleted, "did", did)
147 }
148}
149
150// Cleanup removes expired sessions
151func (s *SessionStore) Cleanup() {
152 result, err := s.db.Exec(`
153 DELETE FROM ui_sessions
154 WHERE expires_at < datetime('now')
155 `)
156
157 if err != nil {
158 slog.Warn("Failed to cleanup sessions", "error", err)
159 return
160 }
161
162 deleted, _ := result.RowsAffected()
163 if deleted > 0 {
164 slog.Info("Cleaned up expired UI sessions", "count", deleted)
165 }
166}
167
168// CleanupContext is a context-aware version of Cleanup for background workers
169func (s *SessionStore) CleanupContext(ctx context.Context) error {
170 result, err := s.db.ExecContext(ctx, `
171 DELETE FROM ui_sessions
172 WHERE expires_at < datetime('now')
173 `)
174
175 if err != nil {
176 return fmt.Errorf("failed to cleanup sessions: %w", err)
177 }
178
179 deleted, _ := result.RowsAffected()
180 if deleted > 0 {
181 slog.Info("Cleaned up expired UI sessions", "count", deleted)
182 }
183
184 return nil
185}
186
187// Cookie helper functions (compatible with pkg/appview/session package)
188
189// SetCookie sets the session cookie
190func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) {
191 http.SetCookie(w, &http.Cookie{
192 Name: "atcr_session",
193 Value: sessionID,
194 Path: "/",
195 MaxAge: maxAge,
196 HttpOnly: true,
197 Secure: true,
198 SameSite: http.SameSiteLaxMode,
199 })
200}
201
202// ClearCookie clears the session cookie
203func ClearCookie(w http.ResponseWriter) {
204 http.SetCookie(w, &http.Cookie{
205 Name: "atcr_session",
206 Value: "",
207 Path: "/",
208 MaxAge: -1,
209 HttpOnly: true,
210 Secure: true,
211 SameSite: http.SameSiteLaxMode,
212 })
213}
214
215// GetSessionID gets session ID from cookie
216func GetSessionID(r *http.Request) (string, bool) {
217 cookie, err := r.Cookie("atcr_session")
218 if err != nil {
219 return "", false
220 }
221 return cookie.Value, true
222}