···11+# README Embedding Feature
22+33+## Overview
44+55+Enhance the repository page (`/r/{handle}/{repository}`) with embedded README content fetched from the source repository, similar to Docker Hub's "Overview" tab.
66+77+## Current State
88+99+The repository page currently shows:
1010+- Repository metadata from OCI annotations
1111+- Short description from `org.opencontainers.image.description`
1212+- External links to source (`org.opencontainers.image.source`) and docs (`org.opencontainers.image.documentation`)
1313+- Tags and manifests lists
1414+1515+## Proposed Feature
1616+1717+Automatically fetch and render README.md content from the source repository when available, displaying it in an "Overview" section on the repository page.
1818+1919+## Implementation Approach
2020+2121+### 1. Source URL Detection
2222+2323+Parse `org.opencontainers.image.source` annotation to detect GitHub repositories:
2424+- Pattern: `https://github.com/{owner}/{repo}`
2525+- Extract owner and repo name
2626+2727+### 2. README Fetching
2828+2929+Fetch README.md from GitHub via raw content URL:
3030+```
3131+https://raw.githubusercontent.com/{owner}/{repo}/{branch}/README.md
3232+```
3333+3434+Try multiple branch names in order:
3535+1. `main`
3636+2. `master`
3737+3. `develop`
3838+3939+Fallback if README not found or fetch fails.
4040+4141+### 3. Markdown Rendering
4242+4343+Use a Go markdown library to render README content:
4444+- **Option A**: `github.com/gomarkdown/markdown` - Pure Go, fast
4545+- **Option B**: `github.com/yuin/goldmark` - CommonMark compliant, extensible
4646+- **Option C**: Call GitHub's markdown API (requires network call)
4747+4848+Recommended: `goldmark` for CommonMark compliance and GitHub-flavored markdown support.
4949+5050+### 4. Caching Strategy
5151+5252+Cache rendered README to avoid repeated fetches:
5353+5454+**Option A: In-memory cache**
5555+- Simple, fast
5656+- Lost on restart
5757+- Good for MVP
5858+5959+**Option B: Database cache**
6060+- Add `readme_html` column to `manifests` table
6161+- Update on new manifest pushes
6262+- Persistent across restarts
6363+- Background job to refresh periodically
6464+6565+**Option C: Hybrid**
6666+- Cache in database
6767+- Also cache in memory for frequently accessed repos
6868+- TTL-based refresh (e.g., 1 hour)
6969+7070+### 5. UI Integration
7171+7272+Add "Overview" section to repository page:
7373+- Show after repository header, before tags/manifests
7474+- Render markdown as HTML
7575+- Apply CSS styling for markdown elements (headings, code blocks, tables, etc.)
7676+- Handle images in README (may need to proxy or allow external images)
7777+7878+## Implementation Steps
7979+8080+1. **Add README fetcher** (`pkg/appview/readme/fetcher.go`)
8181+ ```go
8282+ type Fetcher struct {
8383+ httpClient *http.Client
8484+ cache Cache
8585+ }
8686+8787+ func (f *Fetcher) FetchGitHubReadme(sourceURL string) (string, error)
8888+ func (f *Fetcher) RenderMarkdown(content string) (string, error)
8989+ ```
9090+9191+2. **Update database schema** (optional, for caching)
9292+ ```sql
9393+ ALTER TABLE manifests ADD COLUMN readme_html TEXT;
9494+ ALTER TABLE manifests ADD COLUMN readme_fetched_at TIMESTAMP;
9595+ ```
9696+9797+3. **Update RepositoryPageHandler**
9898+ - Fetch README for repository
9999+ - Pass rendered HTML to template
100100+101101+4. **Update repository.html template**
102102+ - Add "Overview" section
103103+ - Render HTML safely (use `template.HTML`)
104104+105105+5. **Add markdown CSS**
106106+ - Style headings, code blocks, lists, tables
107107+ - Syntax highlighting for code blocks (optional)
108108+109109+## Security Considerations
110110+111111+1. **XSS Prevention**
112112+ - Sanitize HTML output from markdown renderer
113113+ - Use `bluemonday` or similar HTML sanitizer
114114+ - Only allow safe HTML elements and attributes
115115+116116+2. **Rate Limiting**
117117+ - Cache aggressively to avoid hitting GitHub rate limits
118118+ - Consider GitHub API instead of raw content (requires token but higher limits)
119119+ - Handle 429 responses gracefully
120120+121121+3. **Image Handling**
122122+ - README may contain images with relative URLs
123123+ - Options:
124124+ - Rewrite image URLs to absolute GitHub URLs
125125+ - Proxy images through ATCR (caching, security)
126126+ - Block external images (simplest, but breaks many READMEs)
127127+128128+4. **Content Size**
129129+ - Limit README size (e.g., 1MB max)
130130+ - Truncate very long READMEs with "View on GitHub" link
131131+132132+## Future Enhancements
133133+134134+1. **Support other platforms**
135135+ - GitLab: `https://gitlab.com/{owner}/{repo}/-/raw/{branch}/README.md`
136136+ - Gitea/Forgejo
137137+ - Bitbucket
138138+139139+2. **Custom README upload**
140140+ - Allow users to upload custom README via UI
141141+ - Store in PDS as `io.atcr.readme` record
142142+ - Priority: custom > source repo
143143+144144+3. **Automatic updates**
145145+ - Background job to refresh READMEs periodically
146146+ - Webhook support to update on push to source repo
147147+148148+4. **Syntax highlighting**
149149+ - Use highlight.js or similar for code blocks
150150+ - Support multiple languages
151151+152152+## Example Flow
153153+154154+1. User pushes image with label: `org.opencontainers.image.source=https://github.com/alice/myapp`
155155+2. Manifest stored with source URL annotation
156156+3. User visits `/r/alice/myapp`
157157+4. RepositoryPageHandler:
158158+ - Checks cache for README
159159+ - If not cached or expired:
160160+ - Fetches `https://raw.githubusercontent.com/alice/myapp/main/README.md`
161161+ - Renders markdown to HTML
162162+ - Sanitizes HTML
163163+ - Caches result
164164+ - Passes README HTML to template
165165+5. Template renders Overview section with README content
166166+167167+## Dependencies
168168+169169+```go
170170+// Markdown rendering
171171+github.com/yuin/goldmark v1.6.0
172172+github.com/yuin/goldmark-emoji v1.0.2 // GitHub emoji support
173173+174174+// HTML sanitization
175175+github.com/microcosm-cc/bluemonday v1.0.26
176176+```
177177+178178+## References
179179+180180+- [OCI Image Spec - Annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md)
181181+- [Docker Hub Overview tab behavior](https://hub.docker.com/)
182182+- [Goldmark documentation](https://github.com/yuin/goldmark)
183183+- [GitHub raw content URLs](https://raw.githubusercontent.com/)
+418
pkg/appview/db/device_store.go
···11+package db
22+33+import (
44+ "context"
55+ "crypto/rand"
66+ "database/sql"
77+ "encoding/base64"
88+ "fmt"
99+ "time"
1010+1111+ "github.com/google/uuid"
1212+ "golang.org/x/crypto/bcrypt"
1313+)
1414+1515+// Device represents an authorized device
1616+type Device struct {
1717+ ID string `json:"id"`
1818+ DID string `json:"did"`
1919+ Handle string `json:"handle"`
2020+ Name string `json:"name"`
2121+ SecretHash string `json:"secret_hash"`
2222+ IPAddress string `json:"ip_address"`
2323+ Location string `json:"location"`
2424+ UserAgent string `json:"user_agent"`
2525+ CreatedAt time.Time `json:"created_at"`
2626+ LastUsed time.Time `json:"last_used"`
2727+}
2828+2929+// PendingAuthorization represents a device awaiting user approval
3030+type PendingAuthorization struct {
3131+ DeviceCode string `json:"device_code"`
3232+ UserCode string `json:"user_code"`
3333+ DeviceName string `json:"device_name"`
3434+ IPAddress string `json:"ip_address"`
3535+ UserAgent string `json:"user_agent"`
3636+ ExpiresAt time.Time `json:"expires_at"`
3737+ ApprovedDID string `json:"approved_did"`
3838+ ApprovedAt time.Time `json:"approved_at"`
3939+ DeviceSecret string `json:"device_secret"`
4040+}
4141+4242+// DeviceStore manages devices and pending authorizations with SQLite persistence
4343+type DeviceStore struct {
4444+ db *sql.DB
4545+}
4646+4747+// NewDeviceStore creates a new SQLite-backed device store
4848+func NewDeviceStore(db *sql.DB) *DeviceStore {
4949+ return &DeviceStore{db: db}
5050+}
5151+5252+// CreatePendingAuth creates a new pending device authorization
5353+func (s *DeviceStore) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) {
5454+ // Generate device code (long, random)
5555+ deviceCodeBytes := make([]byte, 32)
5656+ if _, err := rand.Read(deviceCodeBytes); err != nil {
5757+ return nil, fmt.Errorf("failed to generate device code: %w", err)
5858+ }
5959+ deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes)
6060+6161+ // Generate user code (short, human-readable)
6262+ userCode := generateUserCode()
6363+6464+ expiresAt := time.Now().Add(10 * time.Minute)
6565+6666+ _, err := s.db.Exec(`
6767+ INSERT INTO pending_device_auth (device_code, user_code, device_name, ip_address, user_agent, expires_at, created_at)
6868+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
6969+ `, deviceCode, userCode, deviceName, ip, userAgent, expiresAt)
7070+7171+ if err != nil {
7272+ return nil, fmt.Errorf("failed to create pending auth: %w", err)
7373+ }
7474+7575+ pending := &PendingAuthorization{
7676+ DeviceCode: deviceCode,
7777+ UserCode: userCode,
7878+ DeviceName: deviceName,
7979+ IPAddress: ip,
8080+ UserAgent: userAgent,
8181+ ExpiresAt: expiresAt,
8282+ }
8383+8484+ return pending, nil
8585+}
8686+8787+// GetPendingByUserCode retrieves a pending auth by user code
8888+func (s *DeviceStore) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) {
8989+ var pending PendingAuthorization
9090+9191+ err := s.db.QueryRow(`
9292+ SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret
9393+ FROM pending_device_auth
9494+ WHERE user_code = ?
9595+ `, userCode).Scan(
9696+ &pending.DeviceCode,
9797+ &pending.UserCode,
9898+ &pending.DeviceName,
9999+ &pending.IPAddress,
100100+ &pending.UserAgent,
101101+ &pending.ExpiresAt,
102102+ &pending.ApprovedDID,
103103+ &pending.ApprovedAt,
104104+ &pending.DeviceSecret,
105105+ )
106106+107107+ if err == sql.ErrNoRows {
108108+ return nil, false
109109+ }
110110+ if err != nil {
111111+ fmt.Printf("Warning: Failed to query pending auth: %v\n", err)
112112+ return nil, false
113113+ }
114114+115115+ // Check if expired
116116+ if time.Now().After(pending.ExpiresAt) {
117117+ return nil, false
118118+ }
119119+120120+ return &pending, true
121121+}
122122+123123+// GetPendingByDeviceCode retrieves a pending auth by device code
124124+func (s *DeviceStore) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) {
125125+ var pending PendingAuthorization
126126+127127+ err := s.db.QueryRow(`
128128+ SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret
129129+ FROM pending_device_auth
130130+ WHERE device_code = ?
131131+ `, deviceCode).Scan(
132132+ &pending.DeviceCode,
133133+ &pending.UserCode,
134134+ &pending.DeviceName,
135135+ &pending.IPAddress,
136136+ &pending.UserAgent,
137137+ &pending.ExpiresAt,
138138+ &pending.ApprovedDID,
139139+ &pending.ApprovedAt,
140140+ &pending.DeviceSecret,
141141+ )
142142+143143+ if err == sql.ErrNoRows {
144144+ return nil, false
145145+ }
146146+ if err != nil {
147147+ fmt.Printf("Warning: Failed to query pending auth: %v\n", err)
148148+ return nil, false
149149+ }
150150+151151+ // Check if expired
152152+ if time.Now().After(pending.ExpiresAt) {
153153+ return nil, false
154154+ }
155155+156156+ return &pending, true
157157+}
158158+159159+// ApprovePending approves a pending authorization and generates device secret
160160+func (s *DeviceStore) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) {
161161+ // Start transaction
162162+ tx, err := s.db.Begin()
163163+ if err != nil {
164164+ return "", fmt.Errorf("failed to start transaction: %w", err)
165165+ }
166166+ defer tx.Rollback()
167167+168168+ // Get pending auth
169169+ var pending PendingAuthorization
170170+ err = tx.QueryRow(`
171171+ SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did
172172+ FROM pending_device_auth
173173+ WHERE user_code = ?
174174+ `, userCode).Scan(
175175+ &pending.DeviceCode,
176176+ &pending.UserCode,
177177+ &pending.DeviceName,
178178+ &pending.IPAddress,
179179+ &pending.UserAgent,
180180+ &pending.ExpiresAt,
181181+ &pending.ApprovedDID,
182182+ )
183183+184184+ if err == sql.ErrNoRows {
185185+ return "", fmt.Errorf("pending authorization not found")
186186+ }
187187+ if err != nil {
188188+ return "", fmt.Errorf("failed to query pending auth: %w", err)
189189+ }
190190+191191+ // Check expiration
192192+ if time.Now().After(pending.ExpiresAt) {
193193+ return "", fmt.Errorf("authorization expired")
194194+ }
195195+196196+ // Check if already approved
197197+ if pending.ApprovedDID != "" {
198198+ return "", fmt.Errorf("already approved")
199199+ }
200200+201201+ // Generate device secret
202202+ secretBytes := make([]byte, 32)
203203+ if _, err := rand.Read(secretBytes); err != nil {
204204+ return "", fmt.Errorf("failed to generate device secret: %w", err)
205205+ }
206206+ deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes)
207207+208208+ // Hash for storage
209209+ secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost)
210210+ if err != nil {
211211+ return "", fmt.Errorf("failed to hash device secret: %w", err)
212212+ }
213213+ secretHash := string(secretHashBytes)
214214+215215+ // Create device record
216216+ deviceID := uuid.New().String()
217217+ now := time.Now()
218218+219219+ _, err = tx.Exec(`
220220+ INSERT INTO devices (id, did, handle, name, secret_hash, ip_address, user_agent, created_at)
221221+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
222222+ `, deviceID, did, handle, pending.DeviceName, secretHash, pending.IPAddress, pending.UserAgent, now)
223223+224224+ if err != nil {
225225+ return "", fmt.Errorf("failed to create device: %w", err)
226226+ }
227227+228228+ // Update pending auth to mark as approved
229229+ _, err = tx.Exec(`
230230+ UPDATE pending_device_auth
231231+ SET approved_did = ?, approved_at = ?, device_secret = ?
232232+ WHERE user_code = ?
233233+ `, did, now, deviceSecret, userCode)
234234+235235+ if err != nil {
236236+ return "", fmt.Errorf("failed to update pending auth: %w", err)
237237+ }
238238+239239+ // Commit transaction
240240+ if err := tx.Commit(); err != nil {
241241+ return "", fmt.Errorf("failed to commit transaction: %w", err)
242242+ }
243243+244244+ return deviceSecret, nil
245245+}
246246+247247+// ValidateDeviceSecret validates a device secret and returns the device
248248+func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) {
249249+ // Query all devices and check bcrypt hash
250250+ rows, err := s.db.Query(`
251251+ SELECT id, did, handle, name, secret_hash, ip_address, location, user_agent, created_at, last_used
252252+ FROM devices
253253+ `)
254254+ if err != nil {
255255+ return nil, fmt.Errorf("failed to query devices: %w", err)
256256+ }
257257+ defer rows.Close()
258258+259259+ for rows.Next() {
260260+ var device Device
261261+ var lastUsed sql.NullTime
262262+263263+ err := rows.Scan(
264264+ &device.ID,
265265+ &device.DID,
266266+ &device.Handle,
267267+ &device.Name,
268268+ &device.SecretHash,
269269+ &device.IPAddress,
270270+ &device.Location,
271271+ &device.UserAgent,
272272+ &device.CreatedAt,
273273+ &lastUsed,
274274+ )
275275+ if err != nil {
276276+ continue
277277+ }
278278+279279+ if lastUsed.Valid {
280280+ device.LastUsed = lastUsed.Time
281281+ }
282282+283283+ // Check if this device's hash matches the secret
284284+ if err := bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret)); err == nil {
285285+ // Update last used asynchronously
286286+ go s.UpdateLastUsed(device.SecretHash)
287287+288288+ return &device, nil
289289+ }
290290+ }
291291+292292+ return nil, fmt.Errorf("invalid device secret")
293293+}
294294+295295+// ListDevices returns all devices for a DID
296296+func (s *DeviceStore) ListDevices(did string) []*Device {
297297+ rows, err := s.db.Query(`
298298+ SELECT id, did, handle, name, ip_address, location, user_agent, created_at, last_used
299299+ FROM devices
300300+ WHERE did = ?
301301+ ORDER BY created_at DESC
302302+ `, did)
303303+304304+ if err != nil {
305305+ fmt.Printf("Warning: Failed to list devices: %v\n", err)
306306+ return []*Device{}
307307+ }
308308+ defer rows.Close()
309309+310310+ var devices []*Device
311311+ for rows.Next() {
312312+ var device Device
313313+ var lastUsed sql.NullTime
314314+315315+ err := rows.Scan(
316316+ &device.ID,
317317+ &device.DID,
318318+ &device.Handle,
319319+ &device.Name,
320320+ &device.IPAddress,
321321+ &device.Location,
322322+ &device.UserAgent,
323323+ &device.CreatedAt,
324324+ &lastUsed,
325325+ )
326326+ if err != nil {
327327+ continue
328328+ }
329329+330330+ if lastUsed.Valid {
331331+ device.LastUsed = lastUsed.Time
332332+ }
333333+334334+ devices = append(devices, &device)
335335+ }
336336+337337+ return devices
338338+}
339339+340340+// RevokeDevice removes a device
341341+func (s *DeviceStore) RevokeDevice(did, deviceID string) error {
342342+ result, err := s.db.Exec(`
343343+ DELETE FROM devices
344344+ WHERE did = ? AND id = ?
345345+ `, did, deviceID)
346346+347347+ if err != nil {
348348+ return fmt.Errorf("failed to revoke device: %w", err)
349349+ }
350350+351351+ rows, _ := result.RowsAffected()
352352+ if rows == 0 {
353353+ return fmt.Errorf("device not found")
354354+ }
355355+356356+ return nil
357357+}
358358+359359+// UpdateLastUsed updates the last used timestamp
360360+func (s *DeviceStore) UpdateLastUsed(secretHash string) error {
361361+ _, err := s.db.Exec(`
362362+ UPDATE devices
363363+ SET last_used = ?
364364+ WHERE secret_hash = ?
365365+ `, time.Now(), secretHash)
366366+367367+ return err
368368+}
369369+370370+// CleanupExpired removes expired pending authorizations
371371+func (s *DeviceStore) CleanupExpired() {
372372+ result, err := s.db.Exec(`
373373+ DELETE FROM pending_device_auth
374374+ WHERE expires_at < datetime('now')
375375+ `)
376376+377377+ if err != nil {
378378+ fmt.Printf("Warning: Failed to cleanup expired pending auths: %v\n", err)
379379+ return
380380+ }
381381+382382+ deleted, _ := result.RowsAffected()
383383+ if deleted > 0 {
384384+ fmt.Printf("Cleaned up %d expired pending device auths\n", deleted)
385385+ }
386386+}
387387+388388+// CleanupExpiredContext is a context-aware version for background workers
389389+func (s *DeviceStore) CleanupExpiredContext(ctx context.Context) error {
390390+ result, err := s.db.ExecContext(ctx, `
391391+ DELETE FROM pending_device_auth
392392+ WHERE expires_at < datetime('now')
393393+ `)
394394+395395+ if err != nil {
396396+ return fmt.Errorf("failed to cleanup expired pending auths: %w", err)
397397+ }
398398+399399+ deleted, _ := result.RowsAffected()
400400+ if deleted > 0 {
401401+ fmt.Printf("Cleaned up %d expired pending device auths\n", deleted)
402402+ }
403403+404404+ return nil
405405+}
406406+407407+// generateUserCode creates a short, human-readable code
408408+// Format: XXXX-XXXX (e.g., "WDJB-MJHT")
409409+// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L)
410410+func generateUserCode() string {
411411+ chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
412412+ code := make([]byte, 8)
413413+ rand.Read(code)
414414+ for i := range code {
415415+ code[i] = chars[int(code[i])%len(chars)]
416416+ }
417417+ return string(code[:4]) + "-" + string(code[4:])
418418+}
+221
pkg/appview/db/oauth_store.go
···11+package db
22+33+import (
44+ "context"
55+ "database/sql"
66+ "encoding/json"
77+ "fmt"
88+ "time"
99+1010+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
1212+)
1313+1414+// OAuthStore implements oauth.ClientAuthStore with SQLite persistence
1515+type OAuthStore struct {
1616+ db *sql.DB
1717+}
1818+1919+// NewOAuthStore creates a new SQLite-backed OAuth store
2020+func NewOAuthStore(db *sql.DB) *OAuthStore {
2121+ return &OAuthStore{db: db}
2222+}
2323+2424+// GetSession retrieves a session by DID and session ID
2525+func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
2626+ sessionKey := makeSessionKey(did.String(), sessionID)
2727+2828+ var sessionDataJSON string
2929+3030+ err := s.db.QueryRowContext(ctx, `
3131+ SELECT session_data
3232+ FROM oauth_sessions
3333+ WHERE session_key = ?
3434+ `, sessionKey).Scan(&sessionDataJSON)
3535+3636+ if err == sql.ErrNoRows {
3737+ return nil, fmt.Errorf("session not found: %s/%s", did, sessionID)
3838+ }
3939+ if err != nil {
4040+ return nil, fmt.Errorf("failed to query session: %w", err)
4141+ }
4242+4343+ // Parse session data JSON
4444+ var sessionData oauth.ClientSessionData
4545+ if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil {
4646+ return nil, fmt.Errorf("failed to parse session data: %w", err)
4747+ }
4848+4949+ return &sessionData, nil
5050+}
5151+5252+// SaveSession saves or updates a session (upsert)
5353+func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
5454+ sessionKey := makeSessionKey(sess.AccountDID.String(), sess.SessionID)
5555+5656+ // Marshal entire session to JSON
5757+ sessionDataJSON, err := json.Marshal(sess)
5858+ if err != nil {
5959+ return fmt.Errorf("failed to marshal session data: %w", err)
6060+ }
6161+6262+ _, err = s.db.ExecContext(ctx, `
6363+ INSERT INTO oauth_sessions (
6464+ session_key, account_did, session_id, session_data,
6565+ created_at, updated_at
6666+ ) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
6767+ ON CONFLICT(session_key) DO UPDATE SET
6868+ session_data = excluded.session_data,
6969+ updated_at = datetime('now')
7070+ `,
7171+ sessionKey,
7272+ sess.AccountDID.String(),
7373+ sess.SessionID,
7474+ string(sessionDataJSON),
7575+ )
7676+7777+ if err != nil {
7878+ return fmt.Errorf("failed to save session: %w", err)
7979+ }
8080+8181+ return nil
8282+}
8383+8484+// DeleteSession removes a session
8585+func (s *OAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
8686+ sessionKey := makeSessionKey(did.String(), sessionID)
8787+8888+ _, err := s.db.ExecContext(ctx, `
8989+ DELETE FROM oauth_sessions WHERE session_key = ?
9090+ `, sessionKey)
9191+9292+ return err
9393+}
9494+9595+// GetAuthRequestInfo retrieves authentication request data by state
9696+func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
9797+ var requestDataJSON string
9898+9999+ err := s.db.QueryRowContext(ctx, `
100100+ SELECT request_data FROM oauth_auth_requests WHERE state = ?
101101+ `, state).Scan(&requestDataJSON)
102102+103103+ if err == sql.ErrNoRows {
104104+ return nil, fmt.Errorf("auth request not found: %s", state)
105105+ }
106106+ if err != nil {
107107+ return nil, fmt.Errorf("failed to query auth request: %w", err)
108108+ }
109109+110110+ var requestData oauth.AuthRequestData
111111+ if err := json.Unmarshal([]byte(requestDataJSON), &requestData); err != nil {
112112+ return nil, fmt.Errorf("failed to parse auth request data: %w", err)
113113+ }
114114+115115+ return &requestData, nil
116116+}
117117+118118+// SaveAuthRequestInfo saves authentication request data
119119+func (s *OAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
120120+ requestDataJSON, err := json.Marshal(info)
121121+ if err != nil {
122122+ return fmt.Errorf("failed to marshal auth request data: %w", err)
123123+ }
124124+125125+ _, err = s.db.ExecContext(ctx, `
126126+ INSERT INTO oauth_auth_requests (state, request_data, created_at)
127127+ VALUES (?, ?, datetime('now'))
128128+ `, info.State, string(requestDataJSON))
129129+130130+ if err != nil {
131131+ return fmt.Errorf("failed to save auth request: %w", err)
132132+ }
133133+134134+ return nil
135135+}
136136+137137+// DeleteAuthRequestInfo removes authentication request data
138138+func (s *OAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
139139+ _, err := s.db.ExecContext(ctx, `
140140+ DELETE FROM oauth_auth_requests WHERE state = ?
141141+ `, state)
142142+143143+ return err
144144+}
145145+146146+// GetLatestSessionForDID returns the most recently updated session for a DID
147147+// This is the key improvement over the file-based store - we can query by timestamp
148148+func (s *OAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) {
149149+ var sessionDataJSON string
150150+ var sessionID string
151151+152152+ err := s.db.QueryRowContext(ctx, `
153153+ SELECT session_id, session_data
154154+ FROM oauth_sessions
155155+ WHERE account_did = ?
156156+ ORDER BY updated_at DESC
157157+ LIMIT 1
158158+ `, did).Scan(&sessionID, &sessionDataJSON)
159159+160160+ if err == sql.ErrNoRows {
161161+ return nil, "", fmt.Errorf("no session found for DID: %s", did)
162162+ }
163163+ if err != nil {
164164+ return nil, "", fmt.Errorf("failed to query session: %w", err)
165165+ }
166166+167167+ // Parse session data JSON
168168+ var sessionData oauth.ClientSessionData
169169+ if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil {
170170+ return nil, "", fmt.Errorf("failed to parse session data: %w", err)
171171+ }
172172+173173+ return &sessionData, sessionID, nil
174174+}
175175+176176+// CleanupOldSessions removes sessions older than the specified duration
177177+func (s *OAuthStore) CleanupOldSessions(ctx context.Context, olderThan time.Duration) error {
178178+ cutoff := time.Now().Add(-olderThan)
179179+180180+ result, err := s.db.ExecContext(ctx, `
181181+ DELETE FROM oauth_sessions
182182+ WHERE updated_at < ?
183183+ `, cutoff)
184184+185185+ if err != nil {
186186+ return fmt.Errorf("failed to cleanup old sessions: %w", err)
187187+ }
188188+189189+ deleted, _ := result.RowsAffected()
190190+ if deleted > 0 {
191191+ fmt.Printf("Cleaned up %d old OAuth sessions (older than %v)\n", deleted, olderThan)
192192+ }
193193+194194+ return nil
195195+}
196196+197197+// CleanupExpiredAuthRequests removes auth requests older than 10 minutes
198198+func (s *OAuthStore) CleanupExpiredAuthRequests(ctx context.Context) error {
199199+ cutoff := time.Now().Add(-10 * time.Minute)
200200+201201+ result, err := s.db.ExecContext(ctx, `
202202+ DELETE FROM oauth_auth_requests
203203+ WHERE created_at < ?
204204+ `, cutoff)
205205+206206+ if err != nil {
207207+ return fmt.Errorf("failed to cleanup auth requests: %w", err)
208208+ }
209209+210210+ deleted, _ := result.RowsAffected()
211211+ if deleted > 0 {
212212+ fmt.Printf("Cleaned up %d expired auth requests\n", deleted)
213213+ }
214214+215215+ return nil
216216+}
217217+218218+// makeSessionKey creates a composite key for session storage
219219+func makeSessionKey(did, sessionID string) string {
220220+ return fmt.Sprintf("%s:%s", did, sessionID)
221221+}
+137
pkg/appview/db/queries.go
···624624 `)
625625 return err
626626}
627627+628628+// GetRepository fetches a specific repository for a user
629629+func GetRepository(db *sql.DB, did, repository string) (*Repository, error) {
630630+ // Get repository summary
631631+ var r Repository
632632+ r.Name = repository
633633+634634+ var tagCount, manifestCount int
635635+ var lastPushStr string
636636+637637+ err := db.QueryRow(`
638638+ SELECT
639639+ COUNT(DISTINCT tag) as tag_count,
640640+ COUNT(DISTINCT digest) as manifest_count,
641641+ MAX(created_at) as last_push
642642+ FROM (
643643+ SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ?
644644+ UNION
645645+ SELECT NULL, digest, created_at FROM manifests WHERE did = ? AND repository = ?
646646+ )
647647+ `, did, repository, did, repository).Scan(&tagCount, &manifestCount, &lastPushStr)
648648+649649+ if err != nil {
650650+ return nil, err
651651+ }
652652+653653+ r.TagCount = tagCount
654654+ r.ManifestCount = manifestCount
655655+656656+ // Parse the timestamp string into time.Time
657657+ if lastPushStr != "" {
658658+ formats := []string{
659659+ time.RFC3339Nano,
660660+ "2006-01-02 15:04:05.999999999-07:00",
661661+ "2006-01-02 15:04:05.999999999",
662662+ time.RFC3339,
663663+ "2006-01-02 15:04:05",
664664+ }
665665+666666+ for _, format := range formats {
667667+ if t, err := time.Parse(format, lastPushStr); err == nil {
668668+ r.LastPush = t
669669+ break
670670+ }
671671+ }
672672+ }
673673+674674+ // Get tags for this repo
675675+ tagRows, err := db.Query(`
676676+ SELECT id, tag, digest, created_at
677677+ FROM tags
678678+ WHERE did = ? AND repository = ?
679679+ ORDER BY created_at DESC
680680+ `, did, repository)
681681+682682+ if err != nil {
683683+ return nil, err
684684+ }
685685+686686+ for tagRows.Next() {
687687+ var t Tag
688688+ t.DID = did
689689+ t.Repository = repository
690690+ if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
691691+ tagRows.Close()
692692+ return nil, err
693693+ }
694694+ r.Tags = append(r.Tags, t)
695695+ }
696696+ tagRows.Close()
697697+698698+ // Get manifests for this repo
699699+ manifestRows, err := db.Query(`
700700+ SELECT id, digest, hold_endpoint, schema_version, media_type,
701701+ config_digest, config_size, raw_manifest, created_at,
702702+ title, description, source_url, documentation_url, licenses, icon_url
703703+ FROM manifests
704704+ WHERE did = ? AND repository = ?
705705+ ORDER BY created_at DESC
706706+ `, did, repository)
707707+708708+ if err != nil {
709709+ return nil, err
710710+ }
711711+712712+ for manifestRows.Next() {
713713+ var m Manifest
714714+ m.DID = did
715715+ m.Repository = repository
716716+717717+ // Use sql.NullString for nullable annotation fields
718718+ var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString
719719+720720+ if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
721721+ &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt,
722722+ &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil {
723723+ manifestRows.Close()
724724+ return nil, err
725725+ }
726726+727727+ // Convert NullString to string
728728+ if title.Valid {
729729+ m.Title = title.String
730730+ }
731731+ if description.Valid {
732732+ m.Description = description.String
733733+ }
734734+ if sourceURL.Valid {
735735+ m.SourceURL = sourceURL.String
736736+ }
737737+ if documentationURL.Valid {
738738+ m.DocumentationURL = documentationURL.String
739739+ }
740740+ if licenses.Valid {
741741+ m.Licenses = licenses.String
742742+ }
743743+ if iconURL.Valid {
744744+ m.IconURL = iconURL.String
745745+ }
746746+747747+ r.Manifests = append(r.Manifests, m)
748748+ }
749749+ manifestRows.Close()
750750+751751+ // Aggregate repository-level annotations from most recent manifest
752752+ if len(r.Manifests) > 0 {
753753+ latest := r.Manifests[0]
754754+ r.Title = latest.Title
755755+ r.Description = latest.Description
756756+ r.SourceURL = latest.SourceURL
757757+ r.DocumentationURL = latest.DocumentationURL
758758+ r.Licenses = latest.Licenses
759759+ r.IconURL = latest.IconURL
760760+ }
761761+762762+ return &r, nil
763763+}
+64-71
pkg/appview/db/schema.go
···7979 completed BOOLEAN NOT NULL DEFAULT 0,
8080 updated_at TIMESTAMP NOT NULL
8181);
8282+8383+CREATE TABLE IF NOT EXISTS oauth_sessions (
8484+ session_key TEXT PRIMARY KEY,
8585+ account_did TEXT NOT NULL,
8686+ session_id TEXT NOT NULL,
8787+ session_data TEXT NOT NULL,
8888+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
8989+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
9090+ UNIQUE(account_did, session_id)
9191+);
9292+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did);
9393+CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC);
9494+9595+CREATE TABLE IF NOT EXISTS oauth_auth_requests (
9696+ state TEXT PRIMARY KEY,
9797+ request_data TEXT NOT NULL,
9898+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
9999+);
100100+CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at);
101101+102102+CREATE TABLE IF NOT EXISTS ui_sessions (
103103+ id TEXT PRIMARY KEY,
104104+ did TEXT NOT NULL,
105105+ handle TEXT NOT NULL,
106106+ pds_endpoint TEXT NOT NULL,
107107+ oauth_session_id TEXT,
108108+ expires_at TIMESTAMP NOT NULL,
109109+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
110110+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
111111+);
112112+CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did);
113113+CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at);
114114+115115+CREATE TABLE IF NOT EXISTS devices (
116116+ id TEXT PRIMARY KEY,
117117+ did TEXT NOT NULL,
118118+ handle TEXT NOT NULL,
119119+ name TEXT NOT NULL,
120120+ secret_hash TEXT NOT NULL UNIQUE,
121121+ ip_address TEXT,
122122+ location TEXT,
123123+ user_agent TEXT,
124124+ created_at TIMESTAMP NOT NULL,
125125+ last_used TIMESTAMP,
126126+ FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE
127127+);
128128+CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did);
129129+CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash);
130130+131131+CREATE TABLE IF NOT EXISTS pending_device_auth (
132132+ device_code TEXT PRIMARY KEY,
133133+ user_code TEXT NOT NULL UNIQUE,
134134+ device_name TEXT NOT NULL,
135135+ ip_address TEXT,
136136+ user_agent TEXT,
137137+ expires_at TIMESTAMP NOT NULL,
138138+ approved_did TEXT,
139139+ approved_at TIMESTAMP,
140140+ device_secret TEXT,
141141+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
142142+);
143143+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code);
144144+CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at);
82145`
8314684147// InitDB initializes the SQLite database with the schema
···105168 // Log but don't fail - column might already exist
106169 }
107170108108- // Migration: Convert old cdn.bsky.app avatar URLs to imgs.blue
109109- if err := migrateCDNURLs(db); err != nil {
110110- // Log but don't fail - not critical
111111- println("Warning: Failed to migrate CDN URLs:", err.Error())
112112- }
113113-114171 // Migration: Add OCI annotation columns to manifests table
115172 annotationColumns := []string{
116173 "title TEXT",
···130187 }
131188132189 return db, nil
133133-}
134134-135135-// migrateCDNURLs converts old cdn.bsky.app avatar URLs to imgs.blue format
136136-// Old format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg
137137-// New format: https://imgs.blue/did:plc:abc123/bafkreibxuy73...
138138-func migrateCDNURLs(db *sql.DB) error {
139139- // Find all users with cdn.bsky.app avatars
140140- rows, err := db.Query(`SELECT did, avatar FROM users WHERE avatar LIKE 'https://cdn.bsky.app/%'`)
141141- if err != nil {
142142- return err
143143- }
144144- defer rows.Close()
145145-146146- updates := []struct {
147147- did string
148148- newURL string
149149- }{}
150150-151151- for rows.Next() {
152152- var did, oldURL string
153153- if err := rows.Scan(&did, &oldURL); err != nil {
154154- continue
155155- }
156156-157157- // Extract CID from old URL
158158- // Format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg
159159- parts := strings.Split(oldURL, "/")
160160- if len(parts) < 7 {
161161- continue
162162- }
163163-164164- // Get the last part which contains CID@format
165165- cidPart := parts[len(parts)-1]
166166- // Strip off @jpeg or @png suffix
167167- cid := strings.Split(cidPart, "@")[0]
168168-169169- // Construct new imgs.blue URL
170170- newURL := "https://imgs.blue/" + did + "/" + cid
171171-172172- updates = append(updates, struct {
173173- did string
174174- newURL string
175175- }{did, newURL})
176176- }
177177-178178- // Update all users
179179- stmt, err := db.Prepare(`UPDATE users SET avatar = ? WHERE did = ?`)
180180- if err != nil {
181181- return err
182182- }
183183- defer stmt.Close()
184184-185185- for _, update := range updates {
186186- if _, err := stmt.Exec(update.newURL, update.did); err != nil {
187187- // Log but continue
188188- println("Warning: Failed to update avatar for", update.did, ":", err.Error())
189189- }
190190- }
191191-192192- if len(updates) > 0 {
193193- println("Migrated", len(updates), "avatar URLs from cdn.bsky.app to imgs.blue")
194194- }
195195-196196- return nil
197197-}
190190+}
+203
pkg/appview/db/session_store.go
···11+package db
22+33+import (
44+ "context"
55+ "crypto/rand"
66+ "database/sql"
77+ "encoding/base64"
88+ "fmt"
99+ "net/http"
1010+ "time"
1111+)
1212+1313+// Session represents a user session
1414+// Compatible with pkg/appview/session.Session
1515+type Session struct {
1616+ ID string
1717+ DID string
1818+ Handle string
1919+ PDSEndpoint string
2020+ OAuthSessionID string // Links to oauth_sessions.session_id
2121+ ExpiresAt time.Time
2222+}
2323+2424+// SessionStoreInterface defines the session storage interface
2525+// Both db.SessionStore and session.Store implement this
2626+type SessionStoreInterface interface {
2727+ Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
2828+ CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
2929+ Get(id string) (*Session, bool)
3030+ Delete(id string)
3131+ Cleanup()
3232+}
3333+3434+// SessionStore manages user sessions with SQLite persistence
3535+type SessionStore struct {
3636+ db *sql.DB
3737+}
3838+3939+// NewSessionStore creates a new SQLite-backed session store
4040+func NewSessionStore(db *sql.DB) *SessionStore {
4141+ return &SessionStore{db: db}
4242+}
4343+4444+// Create creates a new session and returns the session ID
4545+func (s *SessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
4646+ return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration)
4747+}
4848+4949+// CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID
5050+func (s *SessionStore) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) {
5151+ // Generate random session ID
5252+ b := make([]byte, 32)
5353+ if _, err := rand.Read(b); err != nil {
5454+ return "", fmt.Errorf("failed to generate session ID: %w", err)
5555+ }
5656+5757+ sessionID := base64.URLEncoding.EncodeToString(b)
5858+ expiresAt := time.Now().Add(duration)
5959+6060+ _, err := s.db.Exec(`
6161+ INSERT INTO ui_sessions (id, did, handle, pds_endpoint, oauth_session_id, expires_at, created_at)
6262+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
6363+ `, sessionID, did, handle, pdsEndpoint, oauthSessionID, expiresAt)
6464+6565+ if err != nil {
6666+ return "", fmt.Errorf("failed to create session: %w", err)
6767+ }
6868+6969+ return sessionID, nil
7070+}
7171+7272+// Get retrieves a session by ID
7373+func (s *SessionStore) Get(id string) (*Session, bool) {
7474+ var sess Session
7575+7676+ err := s.db.QueryRow(`
7777+ SELECT id, did, handle, pds_endpoint, oauth_session_id, expires_at
7878+ FROM ui_sessions
7979+ WHERE id = ?
8080+ `, id).Scan(&sess.ID, &sess.DID, &sess.Handle, &sess.PDSEndpoint, &sess.OAuthSessionID, &sess.ExpiresAt)
8181+8282+ if err == sql.ErrNoRows {
8383+ return nil, false
8484+ }
8585+ if err != nil {
8686+ fmt.Printf("Warning: Failed to query session: %v\n", err)
8787+ return nil, false
8888+ }
8989+9090+ // Check if expired
9191+ if time.Now().After(sess.ExpiresAt) {
9292+ return nil, false
9393+ }
9494+9595+ return &sess, true
9696+}
9797+9898+// Extend extends a session's expiration time
9999+func (s *SessionStore) Extend(id string, duration time.Duration) error {
100100+ expiresAt := time.Now().Add(duration)
101101+102102+ result, err := s.db.Exec(`
103103+ UPDATE ui_sessions
104104+ SET expires_at = ?
105105+ WHERE id = ?
106106+ `, expiresAt, id)
107107+108108+ if err != nil {
109109+ return fmt.Errorf("failed to extend session: %w", err)
110110+ }
111111+112112+ rows, _ := result.RowsAffected()
113113+ if rows == 0 {
114114+ return fmt.Errorf("session not found: %s", id)
115115+ }
116116+117117+ return nil
118118+}
119119+120120+// Delete removes a session
121121+func (s *SessionStore) Delete(id string) {
122122+ _, err := s.db.Exec(`
123123+ DELETE FROM ui_sessions WHERE id = ?
124124+ `, id)
125125+126126+ if err != nil {
127127+ fmt.Printf("Warning: Failed to delete session: %v\n", err)
128128+ }
129129+}
130130+131131+// Cleanup removes expired sessions
132132+func (s *SessionStore) Cleanup() {
133133+ result, err := s.db.Exec(`
134134+ DELETE FROM ui_sessions
135135+ WHERE expires_at < datetime('now')
136136+ `)
137137+138138+ if err != nil {
139139+ fmt.Printf("Warning: Failed to cleanup sessions: %v\n", err)
140140+ return
141141+ }
142142+143143+ deleted, _ := result.RowsAffected()
144144+ if deleted > 0 {
145145+ fmt.Printf("Cleaned up %d expired UI sessions\n", deleted)
146146+ }
147147+}
148148+149149+// CleanupContext is a context-aware version of Cleanup for background workers
150150+func (s *SessionStore) CleanupContext(ctx context.Context) error {
151151+ result, err := s.db.ExecContext(ctx, `
152152+ DELETE FROM ui_sessions
153153+ WHERE expires_at < datetime('now')
154154+ `)
155155+156156+ if err != nil {
157157+ return fmt.Errorf("failed to cleanup sessions: %w", err)
158158+ }
159159+160160+ deleted, _ := result.RowsAffected()
161161+ if deleted > 0 {
162162+ fmt.Printf("Cleaned up %d expired UI sessions\n", deleted)
163163+ }
164164+165165+ return nil
166166+}
167167+168168+// Cookie helper functions (compatible with pkg/appview/session package)
169169+170170+// SetCookie sets the session cookie
171171+func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) {
172172+ http.SetCookie(w, &http.Cookie{
173173+ Name: "atcr_session",
174174+ Value: sessionID,
175175+ Path: "/",
176176+ MaxAge: maxAge,
177177+ HttpOnly: true,
178178+ Secure: true,
179179+ SameSite: http.SameSiteLaxMode,
180180+ })
181181+}
182182+183183+// ClearCookie clears the session cookie
184184+func ClearCookie(w http.ResponseWriter) {
185185+ http.SetCookie(w, &http.Cookie{
186186+ Name: "atcr_session",
187187+ Value: "",
188188+ Path: "/",
189189+ MaxAge: -1,
190190+ HttpOnly: true,
191191+ Secure: true,
192192+ SameSite: http.SameSiteLaxMode,
193193+ })
194194+}
195195+196196+// GetSessionID gets session ID from cookie
197197+func GetSessionID(r *http.Request) (string, bool) {
198198+ cookie, err := r.Cookie("atcr_session")
199199+ if err != nil {
200200+ return "", false
201201+ }
202202+ return cookie.Value, true
203203+}
-395
pkg/appview/device/store.go
···11-package device
22-33-import (
44- "crypto/rand"
55- "encoding/base64"
66- "encoding/json"
77- "fmt"
88- "os"
99- "sync"
1010- "time"
1111-1212- "github.com/google/uuid"
1313- "golang.org/x/crypto/bcrypt"
1414-)
1515-1616-// Device represents an authorized device
1717-type Device struct {
1818- ID string `json:"id"` // UUID
1919- DID string `json:"did"` // Owner DID (links to OAuth session)
2020- Handle string `json:"handle"` // Owner handle
2121- Name string `json:"name"` // Device name (hostname)
2222- SecretHash string `json:"secret_hash"` // bcrypt hash of device secret
2323- IPAddress string `json:"ip_address"` // Registration IP
2424- Location string `json:"location"` // GeoIP location (optional)
2525- UserAgent string `json:"user_agent"` // Client info
2626- CreatedAt time.Time `json:"created_at"`
2727- LastUsed time.Time `json:"last_used"`
2828-}
2929-3030-// PendingAuthorization represents a device awaiting user approval
3131-type PendingAuthorization struct {
3232- DeviceCode string `json:"device_code"` // Long code for polling
3333- UserCode string `json:"user_code"` // Short code shown to user
3434- DeviceName string `json:"device_name"` // Device hostname
3535- IPAddress string `json:"ip_address"` // Request IP
3636- UserAgent string `json:"user_agent"` // Client user agent
3737- ExpiresAt time.Time `json:"expires_at"` // Expiration (10 minutes)
3838- ApprovedDID string `json:"approved_did"` // Set when approved
3939- ApprovedAt time.Time `json:"approved_at"` // Set when approved
4040- DeviceSecret string `json:"device_secret"` // Generated after approval
4141-}
4242-4343-// Store manages devices and pending authorizations
4444-type Store struct {
4545- mu sync.RWMutex
4646- devices map[string]*Device // secretHash -> Device
4747- byDID map[string][]string // DID -> []secretHash
4848- pending map[string]*PendingAuthorization // deviceCode -> pending auth
4949- pendingByUser map[string]*PendingAuthorization // userCode -> pending auth
5050- filePath string
5151-}
5252-5353-// persistentData is saved to disk
5454-type persistentData struct {
5555- Devices []*Device `json:"devices"`
5656- Pending []*PendingAuthorization `json:"pending"`
5757-}
5858-5959-// NewStore creates a new device store
6060-func NewStore(filePath string) (*Store, error) {
6161- s := &Store{
6262- devices: make(map[string]*Device),
6363- byDID: make(map[string][]string),
6464- pending: make(map[string]*PendingAuthorization),
6565- pendingByUser: make(map[string]*PendingAuthorization),
6666- filePath: filePath,
6767- }
6868-6969- // Load existing data
7070- if err := s.load(); err != nil && !os.IsNotExist(err) {
7171- return nil, fmt.Errorf("failed to load devices: %w", err)
7272- }
7373-7474- return s, nil
7575-}
7676-7777-// CreatePendingAuth creates a new pending device authorization
7878-func (s *Store) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) {
7979- s.mu.Lock()
8080- defer s.mu.Unlock()
8181-8282- // Generate device code (long, random)
8383- deviceCodeBytes := make([]byte, 32)
8484- if _, err := rand.Read(deviceCodeBytes); err != nil {
8585- return nil, fmt.Errorf("failed to generate device code: %w", err)
8686- }
8787- deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes)
8888-8989- // Generate user code (short, human-readable)
9090- userCode := generateUserCode()
9191-9292- pending := &PendingAuthorization{
9393- DeviceCode: deviceCode,
9494- UserCode: userCode,
9595- DeviceName: deviceName,
9696- IPAddress: ip,
9797- UserAgent: userAgent,
9898- ExpiresAt: time.Now().Add(10 * time.Minute),
9999- }
100100-101101- s.pending[deviceCode] = pending
102102- s.pendingByUser[userCode] = pending
103103-104104- if err := s.save(); err != nil {
105105- return nil, fmt.Errorf("failed to save pending auth: %w", err)
106106- }
107107-108108- return pending, nil
109109-}
110110-111111-// GetPendingByUserCode retrieves a pending auth by user code
112112-func (s *Store) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) {
113113- s.mu.RLock()
114114- defer s.mu.RUnlock()
115115-116116- pending, ok := s.pendingByUser[userCode]
117117- if !ok || time.Now().After(pending.ExpiresAt) {
118118- return nil, false
119119- }
120120-121121- return pending, true
122122-}
123123-124124-// GetPendingByDeviceCode retrieves a pending auth by device code
125125-func (s *Store) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) {
126126- s.mu.RLock()
127127- defer s.mu.RUnlock()
128128-129129- pending, ok := s.pending[deviceCode]
130130- if !ok || time.Now().After(pending.ExpiresAt) {
131131- return nil, false
132132- }
133133-134134- return pending, true
135135-}
136136-137137-// ApprovePending approves a pending authorization and generates device secret
138138-func (s *Store) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) {
139139- s.mu.Lock()
140140- defer s.mu.Unlock()
141141-142142- pending, ok := s.pendingByUser[userCode]
143143- if !ok {
144144- return "", fmt.Errorf("pending authorization not found")
145145- }
146146-147147- if time.Now().After(pending.ExpiresAt) {
148148- return "", fmt.Errorf("authorization expired")
149149- }
150150-151151- if pending.ApprovedDID != "" {
152152- return "", fmt.Errorf("already approved")
153153- }
154154-155155- // Generate device secret
156156- secretBytes := make([]byte, 32)
157157- if _, err := rand.Read(secretBytes); err != nil {
158158- return "", fmt.Errorf("failed to generate device secret: %w", err)
159159- }
160160- deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes)
161161-162162- // Hash for storage
163163- secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost)
164164- if err != nil {
165165- return "", fmt.Errorf("failed to hash device secret: %w", err)
166166- }
167167- secretHash := string(secretHashBytes)
168168-169169- // Create device record
170170- device := &Device{
171171- ID: uuid.New().String(),
172172- DID: did,
173173- Handle: handle,
174174- Name: pending.DeviceName,
175175- SecretHash: secretHash,
176176- IPAddress: pending.IPAddress,
177177- UserAgent: pending.UserAgent,
178178- CreatedAt: time.Now(),
179179- LastUsed: time.Time{}, // Never used yet
180180- }
181181-182182- // Store device
183183- s.devices[secretHash] = device
184184- s.byDID[did] = append(s.byDID[did], secretHash)
185185-186186- // Mark pending as approved
187187- pending.ApprovedDID = did
188188- pending.ApprovedAt = time.Now()
189189- pending.DeviceSecret = deviceSecret // Store plaintext temporarily for polling
190190-191191- if err := s.save(); err != nil {
192192- return "", fmt.Errorf("failed to save device: %w", err)
193193- }
194194-195195- return deviceSecret, nil
196196-}
197197-198198-// ValidateDeviceSecret validates a device secret and returns the device
199199-func (s *Store) ValidateDeviceSecret(secret string) (*Device, error) {
200200- s.mu.RLock()
201201- defer s.mu.RUnlock()
202202-203203- // Try to match against all stored hashes
204204- for hash, device := range s.devices {
205205- if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)); err == nil {
206206- // Update last used asynchronously
207207- go s.UpdateLastUsed(hash)
208208-209209- // Return a copy
210210- deviceCopy := *device
211211- return &deviceCopy, nil
212212- }
213213- }
214214-215215- return nil, fmt.Errorf("invalid device secret")
216216-}
217217-218218-// ListDevices returns all devices for a DID
219219-func (s *Store) ListDevices(did string) []*Device {
220220- s.mu.RLock()
221221- defer s.mu.RUnlock()
222222-223223- hashes, ok := s.byDID[did]
224224- if !ok {
225225- return []*Device{}
226226- }
227227-228228- result := make([]*Device, 0, len(hashes))
229229- for _, hash := range hashes {
230230- if device, ok := s.devices[hash]; ok {
231231- // Return copy without hash
232232- deviceCopy := *device
233233- deviceCopy.SecretHash = ""
234234- result = append(result, &deviceCopy)
235235- }
236236- }
237237-238238- return result
239239-}
240240-241241-// RevokeDevice removes a device
242242-func (s *Store) RevokeDevice(did, deviceID string) error {
243243- s.mu.Lock()
244244- defer s.mu.Unlock()
245245-246246- hashes, ok := s.byDID[did]
247247- if !ok {
248248- return fmt.Errorf("no devices found for DID")
249249- }
250250-251251- var foundHash string
252252- for _, hash := range hashes {
253253- if device, ok := s.devices[hash]; ok && device.ID == deviceID {
254254- foundHash = hash
255255- break
256256- }
257257- }
258258-259259- if foundHash == "" {
260260- return fmt.Errorf("device not found")
261261- }
262262-263263- // Remove from devices map
264264- delete(s.devices, foundHash)
265265-266266- // Remove from byDID index
267267- newHashes := make([]string, 0, len(hashes)-1)
268268- for _, hash := range hashes {
269269- if hash != foundHash {
270270- newHashes = append(newHashes, hash)
271271- }
272272- }
273273-274274- if len(newHashes) == 0 {
275275- delete(s.byDID, did)
276276- } else {
277277- s.byDID[did] = newHashes
278278- }
279279-280280- return s.save()
281281-}
282282-283283-// UpdateLastUsed updates the last used timestamp
284284-func (s *Store) UpdateLastUsed(secretHash string) error {
285285- s.mu.Lock()
286286- defer s.mu.Unlock()
287287-288288- device, ok := s.devices[secretHash]
289289- if !ok {
290290- return fmt.Errorf("device not found")
291291- }
292292-293293- device.LastUsed = time.Now()
294294- return s.save()
295295-}
296296-297297-// CleanupExpired removes expired pending authorizations
298298-func (s *Store) CleanupExpired() {
299299- s.mu.Lock()
300300- defer s.mu.Unlock()
301301-302302- now := time.Now()
303303- modified := false
304304-305305- for deviceCode, pending := range s.pending {
306306- if now.After(pending.ExpiresAt) {
307307- delete(s.pending, deviceCode)
308308- delete(s.pendingByUser, pending.UserCode)
309309- modified = true
310310- }
311311- }
312312-313313- if modified {
314314- s.save()
315315- }
316316-}
317317-318318-// load reads data from disk
319319-func (s *Store) load() error {
320320- data, err := os.ReadFile(s.filePath)
321321- if err != nil {
322322- return err
323323- }
324324-325325- var pd persistentData
326326- if err := json.Unmarshal(data, &pd); err != nil {
327327- return fmt.Errorf("failed to unmarshal devices: %w", err)
328328- }
329329-330330- // Rebuild in-memory structures
331331- for _, device := range pd.Devices {
332332- s.devices[device.SecretHash] = device
333333- s.byDID[device.DID] = append(s.byDID[device.DID], device.SecretHash)
334334- }
335335-336336- for _, pending := range pd.Pending {
337337- // Only load non-expired
338338- if time.Now().Before(pending.ExpiresAt) {
339339- s.pending[pending.DeviceCode] = pending
340340- s.pendingByUser[pending.UserCode] = pending
341341- }
342342- }
343343-344344- return nil
345345-}
346346-347347-// save writes data to disk
348348-func (s *Store) save() error {
349349- // Collect all devices
350350- allDevices := make([]*Device, 0, len(s.devices))
351351- for _, device := range s.devices {
352352- allDevices = append(allDevices, device)
353353- }
354354-355355- // Collect all pending
356356- allPending := make([]*PendingAuthorization, 0, len(s.pending))
357357- for _, pending := range s.pending {
358358- allPending = append(allPending, pending)
359359- }
360360-361361- pd := persistentData{
362362- Devices: allDevices,
363363- Pending: allPending,
364364- }
365365-366366- data, err := json.MarshalIndent(pd, "", " ")
367367- if err != nil {
368368- return fmt.Errorf("failed to marshal devices: %w", err)
369369- }
370370-371371- // Write atomically
372372- tmpPath := s.filePath + ".tmp"
373373- if err := os.WriteFile(tmpPath, data, 0600); err != nil {
374374- return fmt.Errorf("failed to write temp file: %w", err)
375375- }
376376-377377- if err := os.Rename(tmpPath, s.filePath); err != nil {
378378- return fmt.Errorf("failed to rename temp file: %w", err)
379379- }
380380-381381- return nil
382382-}
383383-384384-// generateUserCode creates a short, human-readable code
385385-// Format: XXXX-XXXX (e.g., "WDJB-MJHT")
386386-// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L)
387387-func generateUserCode() string {
388388- chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
389389- code := make([]byte, 8)
390390- rand.Read(code)
391391- for i := range code {
392392- code[i] = chars[int(code[i])%len(chars)]
393393- }
394394- return string(code[:4]) + "-" + string(code[4:])
395395-}
+16-17
pkg/appview/handlers/device.go
···991010 "github.com/gorilla/mux"
11111212- "atcr.io/pkg/appview/device"
1313- "atcr.io/pkg/appview/session"
1212+ "atcr.io/pkg/appview/db"
1413)
15141615// DeviceCodeRequest is the request to start device authorization
···29283029// DeviceCodeHandler handles POST /auth/device/code
3130type DeviceCodeHandler struct {
3232- Store *device.Store
3131+ Store *db.DeviceStore
3332 AppViewBaseURL string // e.g., "http://localhost:5000"
3433}
3534···91909291// DeviceTokenHandler handles POST /auth/device/token
9392type DeviceTokenHandler struct {
9494- Store *device.Store
9393+ Store *db.DeviceStore
9594}
96959796func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···151150152151// DeviceApprovalPageHandler handles GET /device
153152type DeviceApprovalPageHandler struct {
154154- Store *device.Store
155155- SessionStore *session.Store
153153+ Store *db.DeviceStore
154154+ SessionStore *db.SessionStore
156155}
157156158157func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···162161 }
163162164163 // Check if user is logged in
165165- sessionID, ok := session.GetSessionID(r)
164164+ sessionID, ok := db.GetSessionID(r)
166165 if !ok {
167166 // Not logged in - redirect to login with return URL
168167 http.SetCookie(w, &http.Cookie{
···222221223222// DeviceApproveHandler handles POST /device/approve
224223type DeviceApproveHandler struct {
225225- Store *device.Store
226226- SessionStore *session.Store
224224+ Store *db.DeviceStore
225225+ SessionStore *db.SessionStore
227226}
228227229228func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···233232 }
234233235234 // Check session
236236- sessionID, ok := session.GetSessionID(r)
235235+ sessionID, ok := db.GetSessionID(r)
237236 if !ok {
238237 http.Error(w, "unauthorized", http.StatusUnauthorized)
239238 return
···271270272271// ListDevicesHandler handles GET /api/devices
273272type ListDevicesHandler struct {
274274- Store *device.Store
275275- SessionStore *session.Store
273273+ Store *db.DeviceStore
274274+ SessionStore *db.SessionStore
276275}
277276278277func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···282281 }
283282284283 // Check session
285285- sessionID, ok := session.GetSessionID(r)
284284+ sessionID, ok := db.GetSessionID(r)
286285 if !ok {
287286 http.Error(w, "unauthorized", http.StatusUnauthorized)
288287 return
···303302304303// RevokeDeviceHandler handles DELETE /api/devices/{id}
305304type RevokeDeviceHandler struct {
306306- Store *device.Store
307307- SessionStore *session.Store
305305+ Store *db.DeviceStore
306306+ SessionStore *db.SessionStore
308307}
309308310309func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···314313 }
315314316315 // Check session
317317- sessionID, ok := session.GetSessionID(r)
316316+ sessionID, ok := db.GetSessionID(r)
318317 if !ok {
319318 http.Error(w, "unauthorized", http.StatusUnauthorized)
320319 return
···345344346345// Helper functions
347346348348-func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *device.PendingAuthorization) {
347347+func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *db.PendingAuthorization) {
349348 tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate))
350349 data := struct {
351350 Handle string
+67
pkg/appview/handlers/repository.go
···11+package handlers
22+33+import (
44+ "database/sql"
55+ "html/template"
66+ "net/http"
77+88+ "atcr.io/pkg/appview/db"
99+ "atcr.io/pkg/appview/middleware"
1010+ "github.com/gorilla/mux"
1111+)
1212+1313+// RepositoryPageHandler handles the public repository page
1414+type RepositoryPageHandler struct {
1515+ DB *sql.DB
1616+ Templates *template.Template
1717+ RegistryURL string
1818+}
1919+2020+func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
2121+ vars := mux.Vars(r)
2222+ handle := vars["handle"]
2323+ repository := vars["repository"]
2424+2525+ // Look up user by handle
2626+ owner, err := db.GetUserByHandle(h.DB, handle)
2727+ if err != nil {
2828+ http.Error(w, err.Error(), http.StatusInternalServerError)
2929+ return
3030+ }
3131+3232+ if owner == nil {
3333+ http.Error(w, "User not found", http.StatusNotFound)
3434+ return
3535+ }
3636+3737+ // Fetch repository data
3838+ repo, err := db.GetRepository(h.DB, owner.DID, repository)
3939+ if err != nil {
4040+ http.Error(w, err.Error(), http.StatusInternalServerError)
4141+ return
4242+ }
4343+4444+ if repo == nil || len(repo.Manifests) == 0 {
4545+ http.Error(w, "Repository not found", http.StatusNotFound)
4646+ return
4747+ }
4848+4949+ data := struct {
5050+ User *db.User // Logged-in user (for nav)
5151+ Owner *db.User // Repository owner
5252+ Repository *db.Repository
5353+ Query string
5454+ RegistryURL string
5555+ }{
5656+ User: middleware.GetUser(r), // May be nil if not logged in
5757+ Owner: owner,
5858+ Repository: repo,
5959+ Query: r.URL.Query().Get("q"),
6060+ RegistryURL: h.RegistryURL,
6161+ }
6262+6363+ if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil {
6464+ http.Error(w, err.Error(), http.StatusInternalServerError)
6565+ return
6666+ }
6767+}
···8181 return nil, fmt.Errorf("failed to parse DID: %w", err)
8282 }
83838484- // Get all sessions for this DID from store
8585- fileStore, ok := r.app.clientApp.Store.(*FileStore)
8686- if !ok {
8787- return nil, fmt.Errorf("store is not a FileStore")
8484+ // Get the latest session for this DID from SQLite store
8585+ // The store must implement GetLatestSessionForDID (returns newest by updated_at)
8686+ type sessionGetter interface {
8787+ GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error)
8888 }
89899090- // Find a session for this DID
9191- sessions := fileStore.ListSessions()
9292- var sessionID string
9393- for _, sessionData := range sessions {
9494- if sessionData.AccountDID.String() == did {
9595- sessionID = sessionData.SessionID
9696- break
9797- }
9090+ getter, ok := r.app.clientApp.Store.(sessionGetter)
9191+ if !ok {
9292+ return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
9893 }
9994100100- if sessionID == "" {
9595+ _, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
9696+ if err != nil {
10197 return nil, fmt.Errorf("no session found for DID: %s", did)
10298 }
10399