A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

move file store cache to sqlite. implement repository page

evan.jarrett.net 08d5fce2 261f1d65

verified
+1810 -805
+60 -63
cmd/registry/serve.go
··· 24 24 // UI components 25 25 "atcr.io/pkg/appview" 26 26 "atcr.io/pkg/appview/db" 27 - "atcr.io/pkg/appview/device" 28 27 uihandlers "atcr.io/pkg/appview/handlers" 29 28 "atcr.io/pkg/appview/jetstream" 30 29 appmiddleware "atcr.io/pkg/appview/middleware" 31 - appsession "atcr.io/pkg/appview/session" 32 30 "github.com/gorilla/mux" 33 31 ) 34 32 ··· 65 63 return fmt.Errorf("failed to parse configuration: %w", err) 66 64 } 67 65 68 - // Initialize OAuth components 69 - fmt.Println("Initializing OAuth components...") 70 - 71 - // 1. Create OAuth session storage 72 - // Allow override via environment variable for Docker deployments 73 - storagePath := os.Getenv("ATCR_TOKEN_STORAGE_PATH") 74 - if storagePath == "" { 75 - var err error 76 - storagePath, err = oauth.GetDefaultStorePath() 77 - if err != nil { 78 - return fmt.Errorf("failed to get storage path: %w", err) 79 - } 66 + // Initialize UI database first (required for all stores) 67 + fmt.Println("Initializing UI database...") 68 + uiDatabase, uiSessionStore := initializeDatabase(config) 69 + if uiDatabase == nil { 70 + return fmt.Errorf("failed to initialize UI database - required for session storage") 80 71 } 81 72 82 - // Ensure directory exists 83 - storageDir := filepath.Dir(storagePath) 84 - if err := os.MkdirAll(storageDir, 0700); err != nil { 85 - return fmt.Errorf("failed to create storage directory: %w", err) 86 - } 87 - 88 - fmt.Printf("Using OAuth session storage path: %s\n", storagePath) 89 - 90 - oauthStore, err := oauth.NewFileStore(storagePath) 91 - if err != nil { 92 - return fmt.Errorf("failed to create OAuth store: %w", err) 93 - } 73 + // Initialize OAuth components 74 + fmt.Println("Initializing OAuth components...") 94 75 95 - // 2. Create device store 96 - deviceStorePath := filepath.Join(filepath.Dir(storagePath), "devices.json") 97 - deviceStore, err := device.NewStore(deviceStorePath) 98 - if err != nil { 99 - return fmt.Errorf("failed to create device store: %w", err) 100 - } 101 - fmt.Printf("Using device storage path: %s\n", deviceStorePath) 76 + // 1. Create OAuth session storage (SQLite-backed) 77 + oauthStore := db.NewOAuthStore(uiDatabase) 78 + fmt.Println("Using SQLite for OAuth session storage") 102 79 103 - // Start background cleanup for expired pending authorizations 104 - go func() { 105 - ticker := time.NewTicker(5 * time.Minute) 106 - defer ticker.Stop() 107 - for range ticker.C { 108 - deviceStore.CleanupExpired() 109 - } 110 - }() 80 + // 2. Create device store (SQLite-backed) 81 + deviceStore := db.NewDeviceStore(uiDatabase) 82 + fmt.Println("Using SQLite for device storage") 111 83 112 84 // 3. Get base URL from config or environment 113 85 baseURL := os.Getenv("ATCR_BASE_URL") ··· 135 107 // 6. Set global refresher for middleware 136 108 middleware.SetGlobalRefresher(refresher) 137 109 138 - // 7. Initialize UI components (get session store for OAuth integration) 139 - uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, oauthApp, refresher, baseURL, deviceStore) 110 + // 7. Initialize UI routes with OAuth app, refresher, and device store 111 + uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiSessionStore, oauthApp, refresher, baseURL, deviceStore) 140 112 141 113 // 8. Create OAuth server 142 114 oauthServer := oauth.NewServer(oauthApp) ··· 147 119 oauthServer.SetUISessionStore(uiSessionStore) 148 120 } 149 121 // Connect database for user avatar management 150 - if uiDatabase != nil { 151 - oauthServer.SetDatabase(uiDatabase) 152 - } 122 + oauthServer.SetDatabase(uiDatabase) 153 123 154 124 // 8. Initialize auth keys and create token issuer 155 125 var issuer *token.Issuer ··· 176 146 mux.Handle("/v2/", app) 177 147 178 148 // Mount UI routes if enabled 179 - if uiDatabase != nil && uiSessionStore != nil && uiTemplates != nil && uiRouter != nil { 149 + if uiSessionStore != nil && uiTemplates != nil && uiRouter != nil { 180 150 // Mount static files 181 151 mux.Handle("/static/", http.StripPrefix("/static/", appview.StaticHandler())) 182 152 ··· 349 319 return "" 350 320 } 351 321 352 - // initializeUI initializes the web UI components 353 - func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *device.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { 322 + // initializeDatabase initializes the SQLite database and session store 323 + func initializeDatabase(config *configuration.Configuration) (*sql.DB, *db.SessionStore) { 354 324 // Check if UI is enabled (optional configuration) 355 325 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 356 326 if uiEnabled == "false" { 357 - return nil, nil, nil, nil 327 + return nil, nil 358 328 } 359 329 360 330 // Get database path ··· 367 337 dbDir := filepath.Dir(dbPath) 368 338 if err := os.MkdirAll(dbDir, 0700); err != nil { 369 339 fmt.Printf("Warning: Failed to create UI database directory: %v\n", err) 370 - return nil, nil, nil, nil 340 + return nil, nil 371 341 } 372 342 373 343 // Initialize database 374 344 database, err := db.InitDB(dbPath) 375 345 if err != nil { 376 346 fmt.Printf("Warning: Failed to initialize UI database: %v\n", err) 377 - return nil, nil, nil, nil 347 + return nil, nil 378 348 } 379 349 380 350 fmt.Printf("UI database initialized at %s\n", dbPath) 381 351 382 - // Create session store with file persistence 383 - sessionStorePath := os.Getenv("ATCR_UI_SESSION_PATH") 384 - if sessionStorePath == "" { 385 - sessionStorePath = "/var/lib/atcr/ui-sessions.json" 386 - } 387 - sessionStore := appsession.NewStore(sessionStorePath) 352 + // Create SQLite-backed session store 353 + sessionStore := db.NewSessionStore(database) 388 354 389 - // Start cleanup goroutine 355 + // Start cleanup goroutines for all SQLite stores 390 356 go func() { 391 357 ticker := time.NewTicker(5 * time.Minute) 392 358 defer ticker.Stop() 393 359 for range ticker.C { 360 + ctx := context.Background() 361 + 362 + // Cleanup UI sessions 394 363 sessionStore.Cleanup() 364 + 365 + // Cleanup OAuth sessions (older than 30 days) 366 + oauthStore := db.NewOAuthStore(database) 367 + oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour) 368 + oauthStore.CleanupExpiredAuthRequests(ctx) 369 + 370 + // Cleanup device pending auths 371 + deviceStore := db.NewDeviceStore(database) 372 + deviceStore.CleanupExpired() 395 373 } 396 374 }() 397 375 376 + return database, sessionStore 377 + } 378 + 379 + // initializeUIRoutes initializes the web UI routes 380 + func initializeUIRoutes(database *sql.DB, sessionStore *db.SessionStore, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *db.DeviceStore) (*template.Template, *mux.Router) { 381 + // Check if UI is enabled 382 + uiEnabled := os.Getenv("ATCR_UI_ENABLED") 383 + if uiEnabled == "false" { 384 + return nil, nil 385 + } 386 + 398 387 // Load templates 399 388 templates, err := appview.Templates() 400 389 if err != nil { 401 390 fmt.Printf("Warning: Failed to load UI templates: %v\n", err) 402 - return nil, nil, nil, nil 391 + return nil, nil 403 392 } 404 393 405 394 // Create router ··· 441 430 }, 442 431 )).Methods("GET") 443 432 433 + router.Handle("/r/{handle}/{repository}", appmiddleware.OptionalAuth(sessionStore, database)( 434 + &uihandlers.RepositoryPageHandler{ 435 + DB: database, 436 + Templates: templates, 437 + RegistryURL: uihandlers.TrimRegistryURL(baseURL), 438 + }, 439 + )).Methods("GET") 440 + 444 441 // Authenticated routes 445 442 authRouter := router.NewRoute().Subrouter() 446 443 authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) ··· 493 490 494 491 // Logout endpoint 495 492 router.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { 496 - if sessionID, ok := appsession.GetSessionID(r); ok { 493 + if sessionID, ok := db.GetSessionID(r); ok { 497 494 sessionStore.Delete(sessionID) 498 495 } 499 - appsession.ClearCookie(w) 496 + db.ClearCookie(w) 500 497 http.Redirect(w, r, "/", http.StatusFound) 501 498 }).Methods("POST") 502 499 ··· 568 565 } 569 566 } 570 567 571 - return database, sessionStore, templates, router 568 + return templates, router 572 569 }
+1 -5
docker-compose.yml
··· 8 8 ports: 9 9 - "5000:5000" 10 10 environment: 11 - - ATCR_TOKEN_STORAGE_PATH=/var/lib/atcr/tokens/oauth-tokens.json 12 11 - ATCR_UI_ENABLED=true 13 12 - ATCR_BACKFILL_ENABLED=true 14 13 volumes: 15 14 # Auth keys (JWT signing keys) 16 15 - atcr-auth:/var/lib/atcr/auth 17 - # OAuth refresh tokens (persists user sessions across container restarts) 18 - - atcr-tokens:/var/lib/atcr/tokens 19 - # UI database (firehose cache for web interface) 16 + # UI database (includes OAuth sessions, devices, and firehose cache) 20 17 - atcr-ui:/var/lib/atcr 21 18 restart: unless-stopped 22 19 dns: ··· 66 63 volumes: 67 64 atcr-hold: 68 65 atcr-auth: 69 - atcr-tokens: 70 66 atcr-ui:
+183
docs/README_EMBEDDING.md
··· 1 + # README Embedding Feature 2 + 3 + ## Overview 4 + 5 + Enhance the repository page (`/r/{handle}/{repository}`) with embedded README content fetched from the source repository, similar to Docker Hub's "Overview" tab. 6 + 7 + ## Current State 8 + 9 + The repository page currently shows: 10 + - Repository metadata from OCI annotations 11 + - Short description from `org.opencontainers.image.description` 12 + - External links to source (`org.opencontainers.image.source`) and docs (`org.opencontainers.image.documentation`) 13 + - Tags and manifests lists 14 + 15 + ## Proposed Feature 16 + 17 + Automatically fetch and render README.md content from the source repository when available, displaying it in an "Overview" section on the repository page. 18 + 19 + ## Implementation Approach 20 + 21 + ### 1. Source URL Detection 22 + 23 + Parse `org.opencontainers.image.source` annotation to detect GitHub repositories: 24 + - Pattern: `https://github.com/{owner}/{repo}` 25 + - Extract owner and repo name 26 + 27 + ### 2. README Fetching 28 + 29 + Fetch README.md from GitHub via raw content URL: 30 + ``` 31 + https://raw.githubusercontent.com/{owner}/{repo}/{branch}/README.md 32 + ``` 33 + 34 + Try multiple branch names in order: 35 + 1. `main` 36 + 2. `master` 37 + 3. `develop` 38 + 39 + Fallback if README not found or fetch fails. 40 + 41 + ### 3. Markdown Rendering 42 + 43 + Use a Go markdown library to render README content: 44 + - **Option A**: `github.com/gomarkdown/markdown` - Pure Go, fast 45 + - **Option B**: `github.com/yuin/goldmark` - CommonMark compliant, extensible 46 + - **Option C**: Call GitHub's markdown API (requires network call) 47 + 48 + Recommended: `goldmark` for CommonMark compliance and GitHub-flavored markdown support. 49 + 50 + ### 4. Caching Strategy 51 + 52 + Cache rendered README to avoid repeated fetches: 53 + 54 + **Option A: In-memory cache** 55 + - Simple, fast 56 + - Lost on restart 57 + - Good for MVP 58 + 59 + **Option B: Database cache** 60 + - Add `readme_html` column to `manifests` table 61 + - Update on new manifest pushes 62 + - Persistent across restarts 63 + - Background job to refresh periodically 64 + 65 + **Option C: Hybrid** 66 + - Cache in database 67 + - Also cache in memory for frequently accessed repos 68 + - TTL-based refresh (e.g., 1 hour) 69 + 70 + ### 5. UI Integration 71 + 72 + Add "Overview" section to repository page: 73 + - Show after repository header, before tags/manifests 74 + - Render markdown as HTML 75 + - Apply CSS styling for markdown elements (headings, code blocks, tables, etc.) 76 + - Handle images in README (may need to proxy or allow external images) 77 + 78 + ## Implementation Steps 79 + 80 + 1. **Add README fetcher** (`pkg/appview/readme/fetcher.go`) 81 + ```go 82 + type Fetcher struct { 83 + httpClient *http.Client 84 + cache Cache 85 + } 86 + 87 + func (f *Fetcher) FetchGitHubReadme(sourceURL string) (string, error) 88 + func (f *Fetcher) RenderMarkdown(content string) (string, error) 89 + ``` 90 + 91 + 2. **Update database schema** (optional, for caching) 92 + ```sql 93 + ALTER TABLE manifests ADD COLUMN readme_html TEXT; 94 + ALTER TABLE manifests ADD COLUMN readme_fetched_at TIMESTAMP; 95 + ``` 96 + 97 + 3. **Update RepositoryPageHandler** 98 + - Fetch README for repository 99 + - Pass rendered HTML to template 100 + 101 + 4. **Update repository.html template** 102 + - Add "Overview" section 103 + - Render HTML safely (use `template.HTML`) 104 + 105 + 5. **Add markdown CSS** 106 + - Style headings, code blocks, lists, tables 107 + - Syntax highlighting for code blocks (optional) 108 + 109 + ## Security Considerations 110 + 111 + 1. **XSS Prevention** 112 + - Sanitize HTML output from markdown renderer 113 + - Use `bluemonday` or similar HTML sanitizer 114 + - Only allow safe HTML elements and attributes 115 + 116 + 2. **Rate Limiting** 117 + - Cache aggressively to avoid hitting GitHub rate limits 118 + - Consider GitHub API instead of raw content (requires token but higher limits) 119 + - Handle 429 responses gracefully 120 + 121 + 3. **Image Handling** 122 + - README may contain images with relative URLs 123 + - Options: 124 + - Rewrite image URLs to absolute GitHub URLs 125 + - Proxy images through ATCR (caching, security) 126 + - Block external images (simplest, but breaks many READMEs) 127 + 128 + 4. **Content Size** 129 + - Limit README size (e.g., 1MB max) 130 + - Truncate very long READMEs with "View on GitHub" link 131 + 132 + ## Future Enhancements 133 + 134 + 1. **Support other platforms** 135 + - GitLab: `https://gitlab.com/{owner}/{repo}/-/raw/{branch}/README.md` 136 + - Gitea/Forgejo 137 + - Bitbucket 138 + 139 + 2. **Custom README upload** 140 + - Allow users to upload custom README via UI 141 + - Store in PDS as `io.atcr.readme` record 142 + - Priority: custom > source repo 143 + 144 + 3. **Automatic updates** 145 + - Background job to refresh READMEs periodically 146 + - Webhook support to update on push to source repo 147 + 148 + 4. **Syntax highlighting** 149 + - Use highlight.js or similar for code blocks 150 + - Support multiple languages 151 + 152 + ## Example Flow 153 + 154 + 1. User pushes image with label: `org.opencontainers.image.source=https://github.com/alice/myapp` 155 + 2. Manifest stored with source URL annotation 156 + 3. User visits `/r/alice/myapp` 157 + 4. RepositoryPageHandler: 158 + - Checks cache for README 159 + - If not cached or expired: 160 + - Fetches `https://raw.githubusercontent.com/alice/myapp/main/README.md` 161 + - Renders markdown to HTML 162 + - Sanitizes HTML 163 + - Caches result 164 + - Passes README HTML to template 165 + 5. Template renders Overview section with README content 166 + 167 + ## Dependencies 168 + 169 + ```go 170 + // Markdown rendering 171 + github.com/yuin/goldmark v1.6.0 172 + github.com/yuin/goldmark-emoji v1.0.2 // GitHub emoji support 173 + 174 + // HTML sanitization 175 + github.com/microcosm-cc/bluemonday v1.0.26 176 + ``` 177 + 178 + ## References 179 + 180 + - [OCI Image Spec - Annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md) 181 + - [Docker Hub Overview tab behavior](https://hub.docker.com/) 182 + - [Goldmark documentation](https://github.com/yuin/goldmark) 183 + - [GitHub raw content URLs](https://raw.githubusercontent.com/)
+418
pkg/appview/db/device_store.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "database/sql" 7 + "encoding/base64" 8 + "fmt" 9 + "time" 10 + 11 + "github.com/google/uuid" 12 + "golang.org/x/crypto/bcrypt" 13 + ) 14 + 15 + // Device represents an authorized device 16 + type Device struct { 17 + ID string `json:"id"` 18 + DID string `json:"did"` 19 + Handle string `json:"handle"` 20 + Name string `json:"name"` 21 + SecretHash string `json:"secret_hash"` 22 + IPAddress string `json:"ip_address"` 23 + Location string `json:"location"` 24 + UserAgent string `json:"user_agent"` 25 + CreatedAt time.Time `json:"created_at"` 26 + LastUsed time.Time `json:"last_used"` 27 + } 28 + 29 + // PendingAuthorization represents a device awaiting user approval 30 + type PendingAuthorization struct { 31 + DeviceCode string `json:"device_code"` 32 + UserCode string `json:"user_code"` 33 + DeviceName string `json:"device_name"` 34 + IPAddress string `json:"ip_address"` 35 + UserAgent string `json:"user_agent"` 36 + ExpiresAt time.Time `json:"expires_at"` 37 + ApprovedDID string `json:"approved_did"` 38 + ApprovedAt time.Time `json:"approved_at"` 39 + DeviceSecret string `json:"device_secret"` 40 + } 41 + 42 + // DeviceStore manages devices and pending authorizations with SQLite persistence 43 + type DeviceStore struct { 44 + db *sql.DB 45 + } 46 + 47 + // NewDeviceStore creates a new SQLite-backed device store 48 + func NewDeviceStore(db *sql.DB) *DeviceStore { 49 + return &DeviceStore{db: db} 50 + } 51 + 52 + // CreatePendingAuth creates a new pending device authorization 53 + func (s *DeviceStore) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) { 54 + // Generate device code (long, random) 55 + deviceCodeBytes := make([]byte, 32) 56 + if _, err := rand.Read(deviceCodeBytes); err != nil { 57 + return nil, fmt.Errorf("failed to generate device code: %w", err) 58 + } 59 + deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes) 60 + 61 + // Generate user code (short, human-readable) 62 + userCode := generateUserCode() 63 + 64 + expiresAt := time.Now().Add(10 * time.Minute) 65 + 66 + _, err := s.db.Exec(` 67 + INSERT INTO pending_device_auth (device_code, user_code, device_name, ip_address, user_agent, expires_at, created_at) 68 + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) 69 + `, deviceCode, userCode, deviceName, ip, userAgent, expiresAt) 70 + 71 + if err != nil { 72 + return nil, fmt.Errorf("failed to create pending auth: %w", err) 73 + } 74 + 75 + pending := &PendingAuthorization{ 76 + DeviceCode: deviceCode, 77 + UserCode: userCode, 78 + DeviceName: deviceName, 79 + IPAddress: ip, 80 + UserAgent: userAgent, 81 + ExpiresAt: expiresAt, 82 + } 83 + 84 + return pending, nil 85 + } 86 + 87 + // GetPendingByUserCode retrieves a pending auth by user code 88 + func (s *DeviceStore) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) { 89 + var pending PendingAuthorization 90 + 91 + err := s.db.QueryRow(` 92 + SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret 93 + FROM pending_device_auth 94 + WHERE user_code = ? 95 + `, userCode).Scan( 96 + &pending.DeviceCode, 97 + &pending.UserCode, 98 + &pending.DeviceName, 99 + &pending.IPAddress, 100 + &pending.UserAgent, 101 + &pending.ExpiresAt, 102 + &pending.ApprovedDID, 103 + &pending.ApprovedAt, 104 + &pending.DeviceSecret, 105 + ) 106 + 107 + if err == sql.ErrNoRows { 108 + return nil, false 109 + } 110 + if err != nil { 111 + fmt.Printf("Warning: Failed to query pending auth: %v\n", err) 112 + return nil, false 113 + } 114 + 115 + // Check if expired 116 + if time.Now().After(pending.ExpiresAt) { 117 + return nil, false 118 + } 119 + 120 + return &pending, true 121 + } 122 + 123 + // GetPendingByDeviceCode retrieves a pending auth by device code 124 + func (s *DeviceStore) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) { 125 + var pending PendingAuthorization 126 + 127 + err := s.db.QueryRow(` 128 + SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did, approved_at, device_secret 129 + FROM pending_device_auth 130 + WHERE device_code = ? 131 + `, deviceCode).Scan( 132 + &pending.DeviceCode, 133 + &pending.UserCode, 134 + &pending.DeviceName, 135 + &pending.IPAddress, 136 + &pending.UserAgent, 137 + &pending.ExpiresAt, 138 + &pending.ApprovedDID, 139 + &pending.ApprovedAt, 140 + &pending.DeviceSecret, 141 + ) 142 + 143 + if err == sql.ErrNoRows { 144 + return nil, false 145 + } 146 + if err != nil { 147 + fmt.Printf("Warning: Failed to query pending auth: %v\n", err) 148 + return nil, false 149 + } 150 + 151 + // Check if expired 152 + if time.Now().After(pending.ExpiresAt) { 153 + return nil, false 154 + } 155 + 156 + return &pending, true 157 + } 158 + 159 + // ApprovePending approves a pending authorization and generates device secret 160 + func (s *DeviceStore) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) { 161 + // Start transaction 162 + tx, err := s.db.Begin() 163 + if err != nil { 164 + return "", fmt.Errorf("failed to start transaction: %w", err) 165 + } 166 + defer tx.Rollback() 167 + 168 + // Get pending auth 169 + var pending PendingAuthorization 170 + err = tx.QueryRow(` 171 + SELECT device_code, user_code, device_name, ip_address, user_agent, expires_at, approved_did 172 + FROM pending_device_auth 173 + WHERE user_code = ? 174 + `, userCode).Scan( 175 + &pending.DeviceCode, 176 + &pending.UserCode, 177 + &pending.DeviceName, 178 + &pending.IPAddress, 179 + &pending.UserAgent, 180 + &pending.ExpiresAt, 181 + &pending.ApprovedDID, 182 + ) 183 + 184 + if err == sql.ErrNoRows { 185 + return "", fmt.Errorf("pending authorization not found") 186 + } 187 + if err != nil { 188 + return "", fmt.Errorf("failed to query pending auth: %w", err) 189 + } 190 + 191 + // Check expiration 192 + if time.Now().After(pending.ExpiresAt) { 193 + return "", fmt.Errorf("authorization expired") 194 + } 195 + 196 + // Check if already approved 197 + if pending.ApprovedDID != "" { 198 + return "", fmt.Errorf("already approved") 199 + } 200 + 201 + // Generate device secret 202 + secretBytes := make([]byte, 32) 203 + if _, err := rand.Read(secretBytes); err != nil { 204 + return "", fmt.Errorf("failed to generate device secret: %w", err) 205 + } 206 + deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes) 207 + 208 + // Hash for storage 209 + secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost) 210 + if err != nil { 211 + return "", fmt.Errorf("failed to hash device secret: %w", err) 212 + } 213 + secretHash := string(secretHashBytes) 214 + 215 + // Create device record 216 + deviceID := uuid.New().String() 217 + now := time.Now() 218 + 219 + _, err = tx.Exec(` 220 + INSERT INTO devices (id, did, handle, name, secret_hash, ip_address, user_agent, created_at) 221 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 222 + `, deviceID, did, handle, pending.DeviceName, secretHash, pending.IPAddress, pending.UserAgent, now) 223 + 224 + if err != nil { 225 + return "", fmt.Errorf("failed to create device: %w", err) 226 + } 227 + 228 + // Update pending auth to mark as approved 229 + _, err = tx.Exec(` 230 + UPDATE pending_device_auth 231 + SET approved_did = ?, approved_at = ?, device_secret = ? 232 + WHERE user_code = ? 233 + `, did, now, deviceSecret, userCode) 234 + 235 + if err != nil { 236 + return "", fmt.Errorf("failed to update pending auth: %w", err) 237 + } 238 + 239 + // Commit transaction 240 + if err := tx.Commit(); err != nil { 241 + return "", fmt.Errorf("failed to commit transaction: %w", err) 242 + } 243 + 244 + return deviceSecret, nil 245 + } 246 + 247 + // ValidateDeviceSecret validates a device secret and returns the device 248 + func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) { 249 + // Query all devices and check bcrypt hash 250 + rows, err := s.db.Query(` 251 + SELECT id, did, handle, name, secret_hash, ip_address, location, user_agent, created_at, last_used 252 + FROM devices 253 + `) 254 + if err != nil { 255 + return nil, fmt.Errorf("failed to query devices: %w", err) 256 + } 257 + defer rows.Close() 258 + 259 + for rows.Next() { 260 + var device Device 261 + var lastUsed sql.NullTime 262 + 263 + err := rows.Scan( 264 + &device.ID, 265 + &device.DID, 266 + &device.Handle, 267 + &device.Name, 268 + &device.SecretHash, 269 + &device.IPAddress, 270 + &device.Location, 271 + &device.UserAgent, 272 + &device.CreatedAt, 273 + &lastUsed, 274 + ) 275 + if err != nil { 276 + continue 277 + } 278 + 279 + if lastUsed.Valid { 280 + device.LastUsed = lastUsed.Time 281 + } 282 + 283 + // Check if this device's hash matches the secret 284 + if err := bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret)); err == nil { 285 + // Update last used asynchronously 286 + go s.UpdateLastUsed(device.SecretHash) 287 + 288 + return &device, nil 289 + } 290 + } 291 + 292 + return nil, fmt.Errorf("invalid device secret") 293 + } 294 + 295 + // ListDevices returns all devices for a DID 296 + func (s *DeviceStore) ListDevices(did string) []*Device { 297 + rows, err := s.db.Query(` 298 + SELECT id, did, handle, name, ip_address, location, user_agent, created_at, last_used 299 + FROM devices 300 + WHERE did = ? 301 + ORDER BY created_at DESC 302 + `, did) 303 + 304 + if err != nil { 305 + fmt.Printf("Warning: Failed to list devices: %v\n", err) 306 + return []*Device{} 307 + } 308 + defer rows.Close() 309 + 310 + var devices []*Device 311 + for rows.Next() { 312 + var device Device 313 + var lastUsed sql.NullTime 314 + 315 + err := rows.Scan( 316 + &device.ID, 317 + &device.DID, 318 + &device.Handle, 319 + &device.Name, 320 + &device.IPAddress, 321 + &device.Location, 322 + &device.UserAgent, 323 + &device.CreatedAt, 324 + &lastUsed, 325 + ) 326 + if err != nil { 327 + continue 328 + } 329 + 330 + if lastUsed.Valid { 331 + device.LastUsed = lastUsed.Time 332 + } 333 + 334 + devices = append(devices, &device) 335 + } 336 + 337 + return devices 338 + } 339 + 340 + // RevokeDevice removes a device 341 + func (s *DeviceStore) RevokeDevice(did, deviceID string) error { 342 + result, err := s.db.Exec(` 343 + DELETE FROM devices 344 + WHERE did = ? AND id = ? 345 + `, did, deviceID) 346 + 347 + if err != nil { 348 + return fmt.Errorf("failed to revoke device: %w", err) 349 + } 350 + 351 + rows, _ := result.RowsAffected() 352 + if rows == 0 { 353 + return fmt.Errorf("device not found") 354 + } 355 + 356 + return nil 357 + } 358 + 359 + // UpdateLastUsed updates the last used timestamp 360 + func (s *DeviceStore) UpdateLastUsed(secretHash string) error { 361 + _, err := s.db.Exec(` 362 + UPDATE devices 363 + SET last_used = ? 364 + WHERE secret_hash = ? 365 + `, time.Now(), secretHash) 366 + 367 + return err 368 + } 369 + 370 + // CleanupExpired removes expired pending authorizations 371 + func (s *DeviceStore) CleanupExpired() { 372 + result, err := s.db.Exec(` 373 + DELETE FROM pending_device_auth 374 + WHERE expires_at < datetime('now') 375 + `) 376 + 377 + if err != nil { 378 + fmt.Printf("Warning: Failed to cleanup expired pending auths: %v\n", err) 379 + return 380 + } 381 + 382 + deleted, _ := result.RowsAffected() 383 + if deleted > 0 { 384 + fmt.Printf("Cleaned up %d expired pending device auths\n", deleted) 385 + } 386 + } 387 + 388 + // CleanupExpiredContext is a context-aware version for background workers 389 + func (s *DeviceStore) CleanupExpiredContext(ctx context.Context) error { 390 + result, err := s.db.ExecContext(ctx, ` 391 + DELETE FROM pending_device_auth 392 + WHERE expires_at < datetime('now') 393 + `) 394 + 395 + if err != nil { 396 + return fmt.Errorf("failed to cleanup expired pending auths: %w", err) 397 + } 398 + 399 + deleted, _ := result.RowsAffected() 400 + if deleted > 0 { 401 + fmt.Printf("Cleaned up %d expired pending device auths\n", deleted) 402 + } 403 + 404 + return nil 405 + } 406 + 407 + // generateUserCode creates a short, human-readable code 408 + // Format: XXXX-XXXX (e.g., "WDJB-MJHT") 409 + // Character set: A-Z excluding ambiguous chars (0, O, I, 1, L) 410 + func generateUserCode() string { 411 + chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" 412 + code := make([]byte, 8) 413 + rand.Read(code) 414 + for i := range code { 415 + code[i] = chars[int(code[i])%len(chars)] 416 + } 417 + return string(code[:4]) + "-" + string(code[4:]) 418 + }
+221
pkg/appview/db/oauth_store.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // OAuthStore implements oauth.ClientAuthStore with SQLite persistence 15 + type OAuthStore struct { 16 + db *sql.DB 17 + } 18 + 19 + // NewOAuthStore creates a new SQLite-backed OAuth store 20 + func NewOAuthStore(db *sql.DB) *OAuthStore { 21 + return &OAuthStore{db: db} 22 + } 23 + 24 + // GetSession retrieves a session by DID and session ID 25 + func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 26 + sessionKey := makeSessionKey(did.String(), sessionID) 27 + 28 + var sessionDataJSON string 29 + 30 + err := s.db.QueryRowContext(ctx, ` 31 + SELECT session_data 32 + FROM oauth_sessions 33 + WHERE session_key = ? 34 + `, sessionKey).Scan(&sessionDataJSON) 35 + 36 + if err == sql.ErrNoRows { 37 + return nil, fmt.Errorf("session not found: %s/%s", did, sessionID) 38 + } 39 + if err != nil { 40 + return nil, fmt.Errorf("failed to query session: %w", err) 41 + } 42 + 43 + // Parse session data JSON 44 + var sessionData oauth.ClientSessionData 45 + if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil { 46 + return nil, fmt.Errorf("failed to parse session data: %w", err) 47 + } 48 + 49 + return &sessionData, nil 50 + } 51 + 52 + // SaveSession saves or updates a session (upsert) 53 + func (s *OAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 54 + sessionKey := makeSessionKey(sess.AccountDID.String(), sess.SessionID) 55 + 56 + // Marshal entire session to JSON 57 + sessionDataJSON, err := json.Marshal(sess) 58 + if err != nil { 59 + return fmt.Errorf("failed to marshal session data: %w", err) 60 + } 61 + 62 + _, err = s.db.ExecContext(ctx, ` 63 + INSERT INTO oauth_sessions ( 64 + session_key, account_did, session_id, session_data, 65 + created_at, updated_at 66 + ) VALUES (?, ?, ?, ?, datetime('now'), datetime('now')) 67 + ON CONFLICT(session_key) DO UPDATE SET 68 + session_data = excluded.session_data, 69 + updated_at = datetime('now') 70 + `, 71 + sessionKey, 72 + sess.AccountDID.String(), 73 + sess.SessionID, 74 + string(sessionDataJSON), 75 + ) 76 + 77 + if err != nil { 78 + return fmt.Errorf("failed to save session: %w", err) 79 + } 80 + 81 + return nil 82 + } 83 + 84 + // DeleteSession removes a session 85 + func (s *OAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 86 + sessionKey := makeSessionKey(did.String(), sessionID) 87 + 88 + _, err := s.db.ExecContext(ctx, ` 89 + DELETE FROM oauth_sessions WHERE session_key = ? 90 + `, sessionKey) 91 + 92 + return err 93 + } 94 + 95 + // GetAuthRequestInfo retrieves authentication request data by state 96 + func (s *OAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 97 + var requestDataJSON string 98 + 99 + err := s.db.QueryRowContext(ctx, ` 100 + SELECT request_data FROM oauth_auth_requests WHERE state = ? 101 + `, state).Scan(&requestDataJSON) 102 + 103 + if err == sql.ErrNoRows { 104 + return nil, fmt.Errorf("auth request not found: %s", state) 105 + } 106 + if err != nil { 107 + return nil, fmt.Errorf("failed to query auth request: %w", err) 108 + } 109 + 110 + var requestData oauth.AuthRequestData 111 + if err := json.Unmarshal([]byte(requestDataJSON), &requestData); err != nil { 112 + return nil, fmt.Errorf("failed to parse auth request data: %w", err) 113 + } 114 + 115 + return &requestData, nil 116 + } 117 + 118 + // SaveAuthRequestInfo saves authentication request data 119 + func (s *OAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 120 + requestDataJSON, err := json.Marshal(info) 121 + if err != nil { 122 + return fmt.Errorf("failed to marshal auth request data: %w", err) 123 + } 124 + 125 + _, err = s.db.ExecContext(ctx, ` 126 + INSERT INTO oauth_auth_requests (state, request_data, created_at) 127 + VALUES (?, ?, datetime('now')) 128 + `, info.State, string(requestDataJSON)) 129 + 130 + if err != nil { 131 + return fmt.Errorf("failed to save auth request: %w", err) 132 + } 133 + 134 + return nil 135 + } 136 + 137 + // DeleteAuthRequestInfo removes authentication request data 138 + func (s *OAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 139 + _, err := s.db.ExecContext(ctx, ` 140 + DELETE FROM oauth_auth_requests WHERE state = ? 141 + `, state) 142 + 143 + return err 144 + } 145 + 146 + // GetLatestSessionForDID returns the most recently updated session for a DID 147 + // This is the key improvement over the file-based store - we can query by timestamp 148 + func (s *OAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) { 149 + var sessionDataJSON string 150 + var sessionID string 151 + 152 + err := s.db.QueryRowContext(ctx, ` 153 + SELECT session_id, session_data 154 + FROM oauth_sessions 155 + WHERE account_did = ? 156 + ORDER BY updated_at DESC 157 + LIMIT 1 158 + `, did).Scan(&sessionID, &sessionDataJSON) 159 + 160 + if err == sql.ErrNoRows { 161 + return nil, "", fmt.Errorf("no session found for DID: %s", did) 162 + } 163 + if err != nil { 164 + return nil, "", fmt.Errorf("failed to query session: %w", err) 165 + } 166 + 167 + // Parse session data JSON 168 + var sessionData oauth.ClientSessionData 169 + if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil { 170 + return nil, "", fmt.Errorf("failed to parse session data: %w", err) 171 + } 172 + 173 + return &sessionData, sessionID, nil 174 + } 175 + 176 + // CleanupOldSessions removes sessions older than the specified duration 177 + func (s *OAuthStore) CleanupOldSessions(ctx context.Context, olderThan time.Duration) error { 178 + cutoff := time.Now().Add(-olderThan) 179 + 180 + result, err := s.db.ExecContext(ctx, ` 181 + DELETE FROM oauth_sessions 182 + WHERE updated_at < ? 183 + `, cutoff) 184 + 185 + if err != nil { 186 + return fmt.Errorf("failed to cleanup old sessions: %w", err) 187 + } 188 + 189 + deleted, _ := result.RowsAffected() 190 + if deleted > 0 { 191 + fmt.Printf("Cleaned up %d old OAuth sessions (older than %v)\n", deleted, olderThan) 192 + } 193 + 194 + return nil 195 + } 196 + 197 + // CleanupExpiredAuthRequests removes auth requests older than 10 minutes 198 + func (s *OAuthStore) CleanupExpiredAuthRequests(ctx context.Context) error { 199 + cutoff := time.Now().Add(-10 * time.Minute) 200 + 201 + result, err := s.db.ExecContext(ctx, ` 202 + DELETE FROM oauth_auth_requests 203 + WHERE created_at < ? 204 + `, cutoff) 205 + 206 + if err != nil { 207 + return fmt.Errorf("failed to cleanup auth requests: %w", err) 208 + } 209 + 210 + deleted, _ := result.RowsAffected() 211 + if deleted > 0 { 212 + fmt.Printf("Cleaned up %d expired auth requests\n", deleted) 213 + } 214 + 215 + return nil 216 + } 217 + 218 + // makeSessionKey creates a composite key for session storage 219 + func makeSessionKey(did, sessionID string) string { 220 + return fmt.Sprintf("%s:%s", did, sessionID) 221 + }
+137
pkg/appview/db/queries.go
··· 624 624 `) 625 625 return err 626 626 } 627 + 628 + // GetRepository fetches a specific repository for a user 629 + func GetRepository(db *sql.DB, did, repository string) (*Repository, error) { 630 + // Get repository summary 631 + var r Repository 632 + r.Name = repository 633 + 634 + var tagCount, manifestCount int 635 + var lastPushStr string 636 + 637 + err := db.QueryRow(` 638 + SELECT 639 + COUNT(DISTINCT tag) as tag_count, 640 + COUNT(DISTINCT digest) as manifest_count, 641 + MAX(created_at) as last_push 642 + FROM ( 643 + SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ? 644 + UNION 645 + SELECT NULL, digest, created_at FROM manifests WHERE did = ? AND repository = ? 646 + ) 647 + `, did, repository, did, repository).Scan(&tagCount, &manifestCount, &lastPushStr) 648 + 649 + if err != nil { 650 + return nil, err 651 + } 652 + 653 + r.TagCount = tagCount 654 + r.ManifestCount = manifestCount 655 + 656 + // Parse the timestamp string into time.Time 657 + if lastPushStr != "" { 658 + formats := []string{ 659 + time.RFC3339Nano, 660 + "2006-01-02 15:04:05.999999999-07:00", 661 + "2006-01-02 15:04:05.999999999", 662 + time.RFC3339, 663 + "2006-01-02 15:04:05", 664 + } 665 + 666 + for _, format := range formats { 667 + if t, err := time.Parse(format, lastPushStr); err == nil { 668 + r.LastPush = t 669 + break 670 + } 671 + } 672 + } 673 + 674 + // Get tags for this repo 675 + tagRows, err := db.Query(` 676 + SELECT id, tag, digest, created_at 677 + FROM tags 678 + WHERE did = ? AND repository = ? 679 + ORDER BY created_at DESC 680 + `, did, repository) 681 + 682 + if err != nil { 683 + return nil, err 684 + } 685 + 686 + for tagRows.Next() { 687 + var t Tag 688 + t.DID = did 689 + t.Repository = repository 690 + if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil { 691 + tagRows.Close() 692 + return nil, err 693 + } 694 + r.Tags = append(r.Tags, t) 695 + } 696 + tagRows.Close() 697 + 698 + // Get manifests for this repo 699 + manifestRows, err := db.Query(` 700 + SELECT id, digest, hold_endpoint, schema_version, media_type, 701 + config_digest, config_size, raw_manifest, created_at, 702 + title, description, source_url, documentation_url, licenses, icon_url 703 + FROM manifests 704 + WHERE did = ? AND repository = ? 705 + ORDER BY created_at DESC 706 + `, did, repository) 707 + 708 + if err != nil { 709 + return nil, err 710 + } 711 + 712 + for manifestRows.Next() { 713 + var m Manifest 714 + m.DID = did 715 + m.Repository = repository 716 + 717 + // Use sql.NullString for nullable annotation fields 718 + var title, description, sourceURL, documentationURL, licenses, iconURL sql.NullString 719 + 720 + if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion, 721 + &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.RawManifest, &m.CreatedAt, 722 + &title, &description, &sourceURL, &documentationURL, &licenses, &iconURL); err != nil { 723 + manifestRows.Close() 724 + return nil, err 725 + } 726 + 727 + // Convert NullString to string 728 + if title.Valid { 729 + m.Title = title.String 730 + } 731 + if description.Valid { 732 + m.Description = description.String 733 + } 734 + if sourceURL.Valid { 735 + m.SourceURL = sourceURL.String 736 + } 737 + if documentationURL.Valid { 738 + m.DocumentationURL = documentationURL.String 739 + } 740 + if licenses.Valid { 741 + m.Licenses = licenses.String 742 + } 743 + if iconURL.Valid { 744 + m.IconURL = iconURL.String 745 + } 746 + 747 + r.Manifests = append(r.Manifests, m) 748 + } 749 + manifestRows.Close() 750 + 751 + // Aggregate repository-level annotations from most recent manifest 752 + if len(r.Manifests) > 0 { 753 + latest := r.Manifests[0] 754 + r.Title = latest.Title 755 + r.Description = latest.Description 756 + r.SourceURL = latest.SourceURL 757 + r.DocumentationURL = latest.DocumentationURL 758 + r.Licenses = latest.Licenses 759 + r.IconURL = latest.IconURL 760 + } 761 + 762 + return &r, nil 763 + }
+64 -71
pkg/appview/db/schema.go
··· 79 79 completed BOOLEAN NOT NULL DEFAULT 0, 80 80 updated_at TIMESTAMP NOT NULL 81 81 ); 82 + 83 + CREATE TABLE IF NOT EXISTS oauth_sessions ( 84 + session_key TEXT PRIMARY KEY, 85 + account_did TEXT NOT NULL, 86 + session_id TEXT NOT NULL, 87 + session_data TEXT NOT NULL, 88 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 89 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 90 + UNIQUE(account_did, session_id) 91 + ); 92 + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_did ON oauth_sessions(account_did); 93 + CREATE INDEX IF NOT EXISTS idx_oauth_sessions_updated ON oauth_sessions(updated_at DESC); 94 + 95 + CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 96 + state TEXT PRIMARY KEY, 97 + request_data TEXT NOT NULL, 98 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 99 + ); 100 + CREATE INDEX IF NOT EXISTS idx_oauth_auth_requests_created ON oauth_auth_requests(created_at); 101 + 102 + CREATE TABLE IF NOT EXISTS ui_sessions ( 103 + id TEXT PRIMARY KEY, 104 + did TEXT NOT NULL, 105 + handle TEXT NOT NULL, 106 + pds_endpoint TEXT NOT NULL, 107 + oauth_session_id TEXT, 108 + expires_at TIMESTAMP NOT NULL, 109 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 110 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 111 + ); 112 + CREATE INDEX IF NOT EXISTS idx_ui_sessions_did ON ui_sessions(did); 113 + CREATE INDEX IF NOT EXISTS idx_ui_sessions_expires ON ui_sessions(expires_at); 114 + 115 + CREATE TABLE IF NOT EXISTS devices ( 116 + id TEXT PRIMARY KEY, 117 + did TEXT NOT NULL, 118 + handle TEXT NOT NULL, 119 + name TEXT NOT NULL, 120 + secret_hash TEXT NOT NULL UNIQUE, 121 + ip_address TEXT, 122 + location TEXT, 123 + user_agent TEXT, 124 + created_at TIMESTAMP NOT NULL, 125 + last_used TIMESTAMP, 126 + FOREIGN KEY(did) REFERENCES users(did) ON DELETE CASCADE 127 + ); 128 + CREATE INDEX IF NOT EXISTS idx_devices_did ON devices(did); 129 + CREATE INDEX IF NOT EXISTS idx_devices_hash ON devices(secret_hash); 130 + 131 + CREATE TABLE IF NOT EXISTS pending_device_auth ( 132 + device_code TEXT PRIMARY KEY, 133 + user_code TEXT NOT NULL UNIQUE, 134 + device_name TEXT NOT NULL, 135 + ip_address TEXT, 136 + user_agent TEXT, 137 + expires_at TIMESTAMP NOT NULL, 138 + approved_did TEXT, 139 + approved_at TIMESTAMP, 140 + device_secret TEXT, 141 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 142 + ); 143 + CREATE INDEX IF NOT EXISTS idx_pending_device_auth_user_code ON pending_device_auth(user_code); 144 + CREATE INDEX IF NOT EXISTS idx_pending_device_auth_expires ON pending_device_auth(expires_at); 82 145 ` 83 146 84 147 // InitDB initializes the SQLite database with the schema ··· 105 168 // Log but don't fail - column might already exist 106 169 } 107 170 108 - // Migration: Convert old cdn.bsky.app avatar URLs to imgs.blue 109 - if err := migrateCDNURLs(db); err != nil { 110 - // Log but don't fail - not critical 111 - println("Warning: Failed to migrate CDN URLs:", err.Error()) 112 - } 113 - 114 171 // Migration: Add OCI annotation columns to manifests table 115 172 annotationColumns := []string{ 116 173 "title TEXT", ··· 130 187 } 131 188 132 189 return db, nil 133 - } 134 - 135 - // migrateCDNURLs converts old cdn.bsky.app avatar URLs to imgs.blue format 136 - // Old format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg 137 - // New format: https://imgs.blue/did:plc:abc123/bafkreibxuy73... 138 - func migrateCDNURLs(db *sql.DB) error { 139 - // Find all users with cdn.bsky.app avatars 140 - rows, err := db.Query(`SELECT did, avatar FROM users WHERE avatar LIKE 'https://cdn.bsky.app/%'`) 141 - if err != nil { 142 - return err 143 - } 144 - defer rows.Close() 145 - 146 - updates := []struct { 147 - did string 148 - newURL string 149 - }{} 150 - 151 - for rows.Next() { 152 - var did, oldURL string 153 - if err := rows.Scan(&did, &oldURL); err != nil { 154 - continue 155 - } 156 - 157 - // Extract CID from old URL 158 - // Format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg 159 - parts := strings.Split(oldURL, "/") 160 - if len(parts) < 7 { 161 - continue 162 - } 163 - 164 - // Get the last part which contains CID@format 165 - cidPart := parts[len(parts)-1] 166 - // Strip off @jpeg or @png suffix 167 - cid := strings.Split(cidPart, "@")[0] 168 - 169 - // Construct new imgs.blue URL 170 - newURL := "https://imgs.blue/" + did + "/" + cid 171 - 172 - updates = append(updates, struct { 173 - did string 174 - newURL string 175 - }{did, newURL}) 176 - } 177 - 178 - // Update all users 179 - stmt, err := db.Prepare(`UPDATE users SET avatar = ? WHERE did = ?`) 180 - if err != nil { 181 - return err 182 - } 183 - defer stmt.Close() 184 - 185 - for _, update := range updates { 186 - if _, err := stmt.Exec(update.newURL, update.did); err != nil { 187 - // Log but continue 188 - println("Warning: Failed to update avatar for", update.did, ":", err.Error()) 189 - } 190 - } 191 - 192 - if len(updates) > 0 { 193 - println("Migrated", len(updates), "avatar URLs from cdn.bsky.app to imgs.blue") 194 - } 195 - 196 - return nil 197 - } 190 + }
+203
pkg/appview/db/session_store.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "database/sql" 7 + "encoding/base64" 8 + "fmt" 9 + "net/http" 10 + "time" 11 + ) 12 + 13 + // Session represents a user session 14 + // Compatible with pkg/appview/session.Session 15 + type Session struct { 16 + ID string 17 + DID string 18 + Handle string 19 + PDSEndpoint string 20 + OAuthSessionID string // Links to oauth_sessions.session_id 21 + ExpiresAt time.Time 22 + } 23 + 24 + // SessionStoreInterface defines the session storage interface 25 + // Both db.SessionStore and session.Store implement this 26 + type SessionStoreInterface interface { 27 + Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) 28 + CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) 29 + Get(id string) (*Session, bool) 30 + Delete(id string) 31 + Cleanup() 32 + } 33 + 34 + // SessionStore manages user sessions with SQLite persistence 35 + type SessionStore struct { 36 + db *sql.DB 37 + } 38 + 39 + // NewSessionStore creates a new SQLite-backed session store 40 + func NewSessionStore(db *sql.DB) *SessionStore { 41 + return &SessionStore{db: db} 42 + } 43 + 44 + // Create creates a new session and returns the session ID 45 + func (s *SessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) { 46 + return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration) 47 + } 48 + 49 + // CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID 50 + func (s *SessionStore) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) { 51 + // Generate random session ID 52 + b := make([]byte, 32) 53 + if _, err := rand.Read(b); err != nil { 54 + return "", fmt.Errorf("failed to generate session ID: %w", err) 55 + } 56 + 57 + sessionID := base64.URLEncoding.EncodeToString(b) 58 + expiresAt := time.Now().Add(duration) 59 + 60 + _, err := s.db.Exec(` 61 + INSERT INTO ui_sessions (id, did, handle, pds_endpoint, oauth_session_id, expires_at, created_at) 62 + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) 63 + `, sessionID, did, handle, pdsEndpoint, oauthSessionID, expiresAt) 64 + 65 + if err != nil { 66 + return "", fmt.Errorf("failed to create session: %w", err) 67 + } 68 + 69 + return sessionID, nil 70 + } 71 + 72 + // Get retrieves a session by ID 73 + func (s *SessionStore) Get(id string) (*Session, bool) { 74 + var sess Session 75 + 76 + err := s.db.QueryRow(` 77 + SELECT id, did, handle, pds_endpoint, oauth_session_id, expires_at 78 + FROM ui_sessions 79 + WHERE id = ? 80 + `, id).Scan(&sess.ID, &sess.DID, &sess.Handle, &sess.PDSEndpoint, &sess.OAuthSessionID, &sess.ExpiresAt) 81 + 82 + if err == sql.ErrNoRows { 83 + return nil, false 84 + } 85 + if err != nil { 86 + fmt.Printf("Warning: Failed to query session: %v\n", err) 87 + return nil, false 88 + } 89 + 90 + // Check if expired 91 + if time.Now().After(sess.ExpiresAt) { 92 + return nil, false 93 + } 94 + 95 + return &sess, true 96 + } 97 + 98 + // Extend extends a session's expiration time 99 + func (s *SessionStore) Extend(id string, duration time.Duration) error { 100 + expiresAt := time.Now().Add(duration) 101 + 102 + result, err := s.db.Exec(` 103 + UPDATE ui_sessions 104 + SET expires_at = ? 105 + WHERE id = ? 106 + `, expiresAt, id) 107 + 108 + if err != nil { 109 + return fmt.Errorf("failed to extend session: %w", err) 110 + } 111 + 112 + rows, _ := result.RowsAffected() 113 + if rows == 0 { 114 + return fmt.Errorf("session not found: %s", id) 115 + } 116 + 117 + return nil 118 + } 119 + 120 + // Delete removes a session 121 + func (s *SessionStore) Delete(id string) { 122 + _, err := s.db.Exec(` 123 + DELETE FROM ui_sessions WHERE id = ? 124 + `, id) 125 + 126 + if err != nil { 127 + fmt.Printf("Warning: Failed to delete session: %v\n", err) 128 + } 129 + } 130 + 131 + // Cleanup removes expired sessions 132 + func (s *SessionStore) Cleanup() { 133 + result, err := s.db.Exec(` 134 + DELETE FROM ui_sessions 135 + WHERE expires_at < datetime('now') 136 + `) 137 + 138 + if err != nil { 139 + fmt.Printf("Warning: Failed to cleanup sessions: %v\n", err) 140 + return 141 + } 142 + 143 + deleted, _ := result.RowsAffected() 144 + if deleted > 0 { 145 + fmt.Printf("Cleaned up %d expired UI sessions\n", deleted) 146 + } 147 + } 148 + 149 + // CleanupContext is a context-aware version of Cleanup for background workers 150 + func (s *SessionStore) CleanupContext(ctx context.Context) error { 151 + result, err := s.db.ExecContext(ctx, ` 152 + DELETE FROM ui_sessions 153 + WHERE expires_at < datetime('now') 154 + `) 155 + 156 + if err != nil { 157 + return fmt.Errorf("failed to cleanup sessions: %w", err) 158 + } 159 + 160 + deleted, _ := result.RowsAffected() 161 + if deleted > 0 { 162 + fmt.Printf("Cleaned up %d expired UI sessions\n", deleted) 163 + } 164 + 165 + return nil 166 + } 167 + 168 + // Cookie helper functions (compatible with pkg/appview/session package) 169 + 170 + // SetCookie sets the session cookie 171 + func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) { 172 + http.SetCookie(w, &http.Cookie{ 173 + Name: "atcr_session", 174 + Value: sessionID, 175 + Path: "/", 176 + MaxAge: maxAge, 177 + HttpOnly: true, 178 + Secure: true, 179 + SameSite: http.SameSiteLaxMode, 180 + }) 181 + } 182 + 183 + // ClearCookie clears the session cookie 184 + func ClearCookie(w http.ResponseWriter) { 185 + http.SetCookie(w, &http.Cookie{ 186 + Name: "atcr_session", 187 + Value: "", 188 + Path: "/", 189 + MaxAge: -1, 190 + HttpOnly: true, 191 + Secure: true, 192 + SameSite: http.SameSiteLaxMode, 193 + }) 194 + } 195 + 196 + // GetSessionID gets session ID from cookie 197 + func GetSessionID(r *http.Request) (string, bool) { 198 + cookie, err := r.Cookie("atcr_session") 199 + if err != nil { 200 + return "", false 201 + } 202 + return cookie.Value, true 203 + }
-395
pkg/appview/device/store.go
··· 1 - package device 2 - 3 - import ( 4 - "crypto/rand" 5 - "encoding/base64" 6 - "encoding/json" 7 - "fmt" 8 - "os" 9 - "sync" 10 - "time" 11 - 12 - "github.com/google/uuid" 13 - "golang.org/x/crypto/bcrypt" 14 - ) 15 - 16 - // Device represents an authorized device 17 - type Device struct { 18 - ID string `json:"id"` // UUID 19 - DID string `json:"did"` // Owner DID (links to OAuth session) 20 - Handle string `json:"handle"` // Owner handle 21 - Name string `json:"name"` // Device name (hostname) 22 - SecretHash string `json:"secret_hash"` // bcrypt hash of device secret 23 - IPAddress string `json:"ip_address"` // Registration IP 24 - Location string `json:"location"` // GeoIP location (optional) 25 - UserAgent string `json:"user_agent"` // Client info 26 - CreatedAt time.Time `json:"created_at"` 27 - LastUsed time.Time `json:"last_used"` 28 - } 29 - 30 - // PendingAuthorization represents a device awaiting user approval 31 - type PendingAuthorization struct { 32 - DeviceCode string `json:"device_code"` // Long code for polling 33 - UserCode string `json:"user_code"` // Short code shown to user 34 - DeviceName string `json:"device_name"` // Device hostname 35 - IPAddress string `json:"ip_address"` // Request IP 36 - UserAgent string `json:"user_agent"` // Client user agent 37 - ExpiresAt time.Time `json:"expires_at"` // Expiration (10 minutes) 38 - ApprovedDID string `json:"approved_did"` // Set when approved 39 - ApprovedAt time.Time `json:"approved_at"` // Set when approved 40 - DeviceSecret string `json:"device_secret"` // Generated after approval 41 - } 42 - 43 - // Store manages devices and pending authorizations 44 - type Store struct { 45 - mu sync.RWMutex 46 - devices map[string]*Device // secretHash -> Device 47 - byDID map[string][]string // DID -> []secretHash 48 - pending map[string]*PendingAuthorization // deviceCode -> pending auth 49 - pendingByUser map[string]*PendingAuthorization // userCode -> pending auth 50 - filePath string 51 - } 52 - 53 - // persistentData is saved to disk 54 - type persistentData struct { 55 - Devices []*Device `json:"devices"` 56 - Pending []*PendingAuthorization `json:"pending"` 57 - } 58 - 59 - // NewStore creates a new device store 60 - func NewStore(filePath string) (*Store, error) { 61 - s := &Store{ 62 - devices: make(map[string]*Device), 63 - byDID: make(map[string][]string), 64 - pending: make(map[string]*PendingAuthorization), 65 - pendingByUser: make(map[string]*PendingAuthorization), 66 - filePath: filePath, 67 - } 68 - 69 - // Load existing data 70 - if err := s.load(); err != nil && !os.IsNotExist(err) { 71 - return nil, fmt.Errorf("failed to load devices: %w", err) 72 - } 73 - 74 - return s, nil 75 - } 76 - 77 - // CreatePendingAuth creates a new pending device authorization 78 - func (s *Store) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) { 79 - s.mu.Lock() 80 - defer s.mu.Unlock() 81 - 82 - // Generate device code (long, random) 83 - deviceCodeBytes := make([]byte, 32) 84 - if _, err := rand.Read(deviceCodeBytes); err != nil { 85 - return nil, fmt.Errorf("failed to generate device code: %w", err) 86 - } 87 - deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes) 88 - 89 - // Generate user code (short, human-readable) 90 - userCode := generateUserCode() 91 - 92 - pending := &PendingAuthorization{ 93 - DeviceCode: deviceCode, 94 - UserCode: userCode, 95 - DeviceName: deviceName, 96 - IPAddress: ip, 97 - UserAgent: userAgent, 98 - ExpiresAt: time.Now().Add(10 * time.Minute), 99 - } 100 - 101 - s.pending[deviceCode] = pending 102 - s.pendingByUser[userCode] = pending 103 - 104 - if err := s.save(); err != nil { 105 - return nil, fmt.Errorf("failed to save pending auth: %w", err) 106 - } 107 - 108 - return pending, nil 109 - } 110 - 111 - // GetPendingByUserCode retrieves a pending auth by user code 112 - func (s *Store) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) { 113 - s.mu.RLock() 114 - defer s.mu.RUnlock() 115 - 116 - pending, ok := s.pendingByUser[userCode] 117 - if !ok || time.Now().After(pending.ExpiresAt) { 118 - return nil, false 119 - } 120 - 121 - return pending, true 122 - } 123 - 124 - // GetPendingByDeviceCode retrieves a pending auth by device code 125 - func (s *Store) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) { 126 - s.mu.RLock() 127 - defer s.mu.RUnlock() 128 - 129 - pending, ok := s.pending[deviceCode] 130 - if !ok || time.Now().After(pending.ExpiresAt) { 131 - return nil, false 132 - } 133 - 134 - return pending, true 135 - } 136 - 137 - // ApprovePending approves a pending authorization and generates device secret 138 - func (s *Store) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) { 139 - s.mu.Lock() 140 - defer s.mu.Unlock() 141 - 142 - pending, ok := s.pendingByUser[userCode] 143 - if !ok { 144 - return "", fmt.Errorf("pending authorization not found") 145 - } 146 - 147 - if time.Now().After(pending.ExpiresAt) { 148 - return "", fmt.Errorf("authorization expired") 149 - } 150 - 151 - if pending.ApprovedDID != "" { 152 - return "", fmt.Errorf("already approved") 153 - } 154 - 155 - // Generate device secret 156 - secretBytes := make([]byte, 32) 157 - if _, err := rand.Read(secretBytes); err != nil { 158 - return "", fmt.Errorf("failed to generate device secret: %w", err) 159 - } 160 - deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes) 161 - 162 - // Hash for storage 163 - secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost) 164 - if err != nil { 165 - return "", fmt.Errorf("failed to hash device secret: %w", err) 166 - } 167 - secretHash := string(secretHashBytes) 168 - 169 - // Create device record 170 - device := &Device{ 171 - ID: uuid.New().String(), 172 - DID: did, 173 - Handle: handle, 174 - Name: pending.DeviceName, 175 - SecretHash: secretHash, 176 - IPAddress: pending.IPAddress, 177 - UserAgent: pending.UserAgent, 178 - CreatedAt: time.Now(), 179 - LastUsed: time.Time{}, // Never used yet 180 - } 181 - 182 - // Store device 183 - s.devices[secretHash] = device 184 - s.byDID[did] = append(s.byDID[did], secretHash) 185 - 186 - // Mark pending as approved 187 - pending.ApprovedDID = did 188 - pending.ApprovedAt = time.Now() 189 - pending.DeviceSecret = deviceSecret // Store plaintext temporarily for polling 190 - 191 - if err := s.save(); err != nil { 192 - return "", fmt.Errorf("failed to save device: %w", err) 193 - } 194 - 195 - return deviceSecret, nil 196 - } 197 - 198 - // ValidateDeviceSecret validates a device secret and returns the device 199 - func (s *Store) ValidateDeviceSecret(secret string) (*Device, error) { 200 - s.mu.RLock() 201 - defer s.mu.RUnlock() 202 - 203 - // Try to match against all stored hashes 204 - for hash, device := range s.devices { 205 - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)); err == nil { 206 - // Update last used asynchronously 207 - go s.UpdateLastUsed(hash) 208 - 209 - // Return a copy 210 - deviceCopy := *device 211 - return &deviceCopy, nil 212 - } 213 - } 214 - 215 - return nil, fmt.Errorf("invalid device secret") 216 - } 217 - 218 - // ListDevices returns all devices for a DID 219 - func (s *Store) ListDevices(did string) []*Device { 220 - s.mu.RLock() 221 - defer s.mu.RUnlock() 222 - 223 - hashes, ok := s.byDID[did] 224 - if !ok { 225 - return []*Device{} 226 - } 227 - 228 - result := make([]*Device, 0, len(hashes)) 229 - for _, hash := range hashes { 230 - if device, ok := s.devices[hash]; ok { 231 - // Return copy without hash 232 - deviceCopy := *device 233 - deviceCopy.SecretHash = "" 234 - result = append(result, &deviceCopy) 235 - } 236 - } 237 - 238 - return result 239 - } 240 - 241 - // RevokeDevice removes a device 242 - func (s *Store) RevokeDevice(did, deviceID string) error { 243 - s.mu.Lock() 244 - defer s.mu.Unlock() 245 - 246 - hashes, ok := s.byDID[did] 247 - if !ok { 248 - return fmt.Errorf("no devices found for DID") 249 - } 250 - 251 - var foundHash string 252 - for _, hash := range hashes { 253 - if device, ok := s.devices[hash]; ok && device.ID == deviceID { 254 - foundHash = hash 255 - break 256 - } 257 - } 258 - 259 - if foundHash == "" { 260 - return fmt.Errorf("device not found") 261 - } 262 - 263 - // Remove from devices map 264 - delete(s.devices, foundHash) 265 - 266 - // Remove from byDID index 267 - newHashes := make([]string, 0, len(hashes)-1) 268 - for _, hash := range hashes { 269 - if hash != foundHash { 270 - newHashes = append(newHashes, hash) 271 - } 272 - } 273 - 274 - if len(newHashes) == 0 { 275 - delete(s.byDID, did) 276 - } else { 277 - s.byDID[did] = newHashes 278 - } 279 - 280 - return s.save() 281 - } 282 - 283 - // UpdateLastUsed updates the last used timestamp 284 - func (s *Store) UpdateLastUsed(secretHash string) error { 285 - s.mu.Lock() 286 - defer s.mu.Unlock() 287 - 288 - device, ok := s.devices[secretHash] 289 - if !ok { 290 - return fmt.Errorf("device not found") 291 - } 292 - 293 - device.LastUsed = time.Now() 294 - return s.save() 295 - } 296 - 297 - // CleanupExpired removes expired pending authorizations 298 - func (s *Store) CleanupExpired() { 299 - s.mu.Lock() 300 - defer s.mu.Unlock() 301 - 302 - now := time.Now() 303 - modified := false 304 - 305 - for deviceCode, pending := range s.pending { 306 - if now.After(pending.ExpiresAt) { 307 - delete(s.pending, deviceCode) 308 - delete(s.pendingByUser, pending.UserCode) 309 - modified = true 310 - } 311 - } 312 - 313 - if modified { 314 - s.save() 315 - } 316 - } 317 - 318 - // load reads data from disk 319 - func (s *Store) load() error { 320 - data, err := os.ReadFile(s.filePath) 321 - if err != nil { 322 - return err 323 - } 324 - 325 - var pd persistentData 326 - if err := json.Unmarshal(data, &pd); err != nil { 327 - return fmt.Errorf("failed to unmarshal devices: %w", err) 328 - } 329 - 330 - // Rebuild in-memory structures 331 - for _, device := range pd.Devices { 332 - s.devices[device.SecretHash] = device 333 - s.byDID[device.DID] = append(s.byDID[device.DID], device.SecretHash) 334 - } 335 - 336 - for _, pending := range pd.Pending { 337 - // Only load non-expired 338 - if time.Now().Before(pending.ExpiresAt) { 339 - s.pending[pending.DeviceCode] = pending 340 - s.pendingByUser[pending.UserCode] = pending 341 - } 342 - } 343 - 344 - return nil 345 - } 346 - 347 - // save writes data to disk 348 - func (s *Store) save() error { 349 - // Collect all devices 350 - allDevices := make([]*Device, 0, len(s.devices)) 351 - for _, device := range s.devices { 352 - allDevices = append(allDevices, device) 353 - } 354 - 355 - // Collect all pending 356 - allPending := make([]*PendingAuthorization, 0, len(s.pending)) 357 - for _, pending := range s.pending { 358 - allPending = append(allPending, pending) 359 - } 360 - 361 - pd := persistentData{ 362 - Devices: allDevices, 363 - Pending: allPending, 364 - } 365 - 366 - data, err := json.MarshalIndent(pd, "", " ") 367 - if err != nil { 368 - return fmt.Errorf("failed to marshal devices: %w", err) 369 - } 370 - 371 - // Write atomically 372 - tmpPath := s.filePath + ".tmp" 373 - if err := os.WriteFile(tmpPath, data, 0600); err != nil { 374 - return fmt.Errorf("failed to write temp file: %w", err) 375 - } 376 - 377 - if err := os.Rename(tmpPath, s.filePath); err != nil { 378 - return fmt.Errorf("failed to rename temp file: %w", err) 379 - } 380 - 381 - return nil 382 - } 383 - 384 - // generateUserCode creates a short, human-readable code 385 - // Format: XXXX-XXXX (e.g., "WDJB-MJHT") 386 - // Character set: A-Z excluding ambiguous chars (0, O, I, 1, L) 387 - func generateUserCode() string { 388 - chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" 389 - code := make([]byte, 8) 390 - rand.Read(code) 391 - for i := range code { 392 - code[i] = chars[int(code[i])%len(chars)] 393 - } 394 - return string(code[:4]) + "-" + string(code[4:]) 395 - }
+16 -17
pkg/appview/handlers/device.go
··· 9 9 10 10 "github.com/gorilla/mux" 11 11 12 - "atcr.io/pkg/appview/device" 13 - "atcr.io/pkg/appview/session" 12 + "atcr.io/pkg/appview/db" 14 13 ) 15 14 16 15 // DeviceCodeRequest is the request to start device authorization ··· 29 28 30 29 // DeviceCodeHandler handles POST /auth/device/code 31 30 type DeviceCodeHandler struct { 32 - Store *device.Store 31 + Store *db.DeviceStore 33 32 AppViewBaseURL string // e.g., "http://localhost:5000" 34 33 } 35 34 ··· 91 90 92 91 // DeviceTokenHandler handles POST /auth/device/token 93 92 type DeviceTokenHandler struct { 94 - Store *device.Store 93 + Store *db.DeviceStore 95 94 } 96 95 97 96 func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 151 150 152 151 // DeviceApprovalPageHandler handles GET /device 153 152 type DeviceApprovalPageHandler struct { 154 - Store *device.Store 155 - SessionStore *session.Store 153 + Store *db.DeviceStore 154 + SessionStore *db.SessionStore 156 155 } 157 156 158 157 func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 162 161 } 163 162 164 163 // Check if user is logged in 165 - sessionID, ok := session.GetSessionID(r) 164 + sessionID, ok := db.GetSessionID(r) 166 165 if !ok { 167 166 // Not logged in - redirect to login with return URL 168 167 http.SetCookie(w, &http.Cookie{ ··· 222 221 223 222 // DeviceApproveHandler handles POST /device/approve 224 223 type DeviceApproveHandler struct { 225 - Store *device.Store 226 - SessionStore *session.Store 224 + Store *db.DeviceStore 225 + SessionStore *db.SessionStore 227 226 } 228 227 229 228 func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 233 232 } 234 233 235 234 // Check session 236 - sessionID, ok := session.GetSessionID(r) 235 + sessionID, ok := db.GetSessionID(r) 237 236 if !ok { 238 237 http.Error(w, "unauthorized", http.StatusUnauthorized) 239 238 return ··· 271 270 272 271 // ListDevicesHandler handles GET /api/devices 273 272 type ListDevicesHandler struct { 274 - Store *device.Store 275 - SessionStore *session.Store 273 + Store *db.DeviceStore 274 + SessionStore *db.SessionStore 276 275 } 277 276 278 277 func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 282 281 } 283 282 284 283 // Check session 285 - sessionID, ok := session.GetSessionID(r) 284 + sessionID, ok := db.GetSessionID(r) 286 285 if !ok { 287 286 http.Error(w, "unauthorized", http.StatusUnauthorized) 288 287 return ··· 303 302 304 303 // RevokeDeviceHandler handles DELETE /api/devices/{id} 305 304 type RevokeDeviceHandler struct { 306 - Store *device.Store 307 - SessionStore *session.Store 305 + Store *db.DeviceStore 306 + SessionStore *db.SessionStore 308 307 } 309 308 310 309 func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 314 313 } 315 314 316 315 // Check session 317 - sessionID, ok := session.GetSessionID(r) 316 + sessionID, ok := db.GetSessionID(r) 318 317 if !ok { 319 318 http.Error(w, "unauthorized", http.StatusUnauthorized) 320 319 return ··· 345 344 346 345 // Helper functions 347 346 348 - func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *device.PendingAuthorization) { 347 + func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *db.PendingAuthorization) { 349 348 tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate)) 350 349 data := struct { 351 350 Handle string
+67
pkg/appview/handlers/repository.go
··· 1 + package handlers 2 + 3 + import ( 4 + "database/sql" 5 + "html/template" 6 + "net/http" 7 + 8 + "atcr.io/pkg/appview/db" 9 + "atcr.io/pkg/appview/middleware" 10 + "github.com/gorilla/mux" 11 + ) 12 + 13 + // RepositoryPageHandler handles the public repository page 14 + type RepositoryPageHandler struct { 15 + DB *sql.DB 16 + Templates *template.Template 17 + RegistryURL string 18 + } 19 + 20 + func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 21 + vars := mux.Vars(r) 22 + handle := vars["handle"] 23 + repository := vars["repository"] 24 + 25 + // Look up user by handle 26 + owner, err := db.GetUserByHandle(h.DB, handle) 27 + if err != nil { 28 + http.Error(w, err.Error(), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + if owner == nil { 33 + http.Error(w, "User not found", http.StatusNotFound) 34 + return 35 + } 36 + 37 + // Fetch repository data 38 + repo, err := db.GetRepository(h.DB, owner.DID, repository) 39 + if err != nil { 40 + http.Error(w, err.Error(), http.StatusInternalServerError) 41 + return 42 + } 43 + 44 + if repo == nil || len(repo.Manifests) == 0 { 45 + http.Error(w, "Repository not found", http.StatusNotFound) 46 + return 47 + } 48 + 49 + data := struct { 50 + User *db.User // Logged-in user (for nav) 51 + Owner *db.User // Repository owner 52 + Repository *db.Repository 53 + Query string 54 + RegistryURL string 55 + }{ 56 + User: middleware.GetUser(r), // May be nil if not logged in 57 + Owner: owner, 58 + Repository: repo, 59 + Query: r.URL.Query().Get("q"), 60 + RegistryURL: h.RegistryURL, 61 + } 62 + 63 + if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { 64 + http.Error(w, err.Error(), http.StatusInternalServerError) 65 + return 66 + } 67 + }
+50
pkg/appview/interfaces.go
··· 1 + package appview 2 + 3 + import "time" 4 + 5 + // SessionStore interface for UI session management 6 + // Implemented by both session.Store (file-based) and db.SessionStore (SQLite-based) 7 + type SessionStore interface { 8 + Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) 9 + CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) 10 + Get(id string) (Session, bool) 11 + Delete(id string) 12 + Cleanup() 13 + } 14 + 15 + // Session represents a user session 16 + // Compatible with both file-based and SQLite implementations 17 + type Session interface { 18 + GetID() string 19 + GetDID() string 20 + GetHandle() string 21 + GetPDSEndpoint() string 22 + GetOAuthSessionID() string 23 + } 24 + 25 + // DeviceStore interface for device authorization management 26 + // Implemented by both device.Store (file-based) and db.DeviceStore (SQLite-based) 27 + type DeviceStore interface { 28 + CreatePendingAuth(deviceName, ip, userAgent string) (PendingAuth, error) 29 + GetPendingByUserCode(userCode string) (PendingAuth, bool) 30 + GetPendingByDeviceCode(deviceCode string) (PendingAuth, bool) 31 + ApprovePending(userCode, did, handle string) (deviceSecret string, err error) 32 + ValidateDeviceSecret(secret string) (Device, error) 33 + ListDevices(did string) []Device 34 + RevokeDevice(did, deviceID string) error 35 + CleanupExpired() 36 + } 37 + 38 + // PendingAuth interface for pending device authorizations 39 + type PendingAuth interface { 40 + GetDeviceCode() string 41 + GetUserCode() string 42 + GetDeviceName() string 43 + } 44 + 45 + // Device interface for authorized devices 46 + type Device interface { 47 + GetID() string 48 + GetDID() string 49 + GetHandle() string 50 + }
+13 -5
pkg/appview/middleware/auth.go
··· 6 6 "net/http" 7 7 8 8 "atcr.io/pkg/appview/db" 9 - "atcr.io/pkg/appview/session" 10 9 ) 11 10 12 11 type contextKey string ··· 14 13 const userKey contextKey = "user" 15 14 16 15 // RequireAuth is middleware that requires authentication 17 - func RequireAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler { 16 + func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler { 18 17 return func(next http.Handler) http.Handler { 19 18 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 - sessionID, ok := session.GetSessionID(r) 19 + sessionID, ok := getSessionID(r) 21 20 if !ok { 22 21 http.Redirect(w, r, "/auth/oauth/login?return_to="+r.URL.Path, http.StatusFound) 23 22 return ··· 47 46 } 48 47 49 48 // OptionalAuth is middleware that optionally includes user if authenticated 50 - func OptionalAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler { 49 + func OptionalAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler { 51 50 return func(next http.Handler) http.Handler { 52 51 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 - sessionID, ok := session.GetSessionID(r) 52 + sessionID, ok := getSessionID(r) 54 53 if ok { 55 54 if sess, ok := store.Get(sessionID); ok { 56 55 // Look up full user from database to get avatar ··· 70 69 next.ServeHTTP(w, r) 71 70 }) 72 71 } 72 + } 73 + 74 + // getSessionID gets session ID from cookie 75 + func getSessionID(r *http.Request) (string, bool) { 76 + cookie, err := r.Cookie("atcr_session") 77 + if err != nil { 78 + return "", false 79 + } 80 + return cookie.Value, true 73 81 } 74 82 75 83 // GetUser retrieves the user from the request context
-228
pkg/appview/session/session.go
··· 1 - package session 2 - 3 - import ( 4 - "crypto/rand" 5 - "encoding/base64" 6 - "encoding/json" 7 - "fmt" 8 - "net/http" 9 - "os" 10 - "sync" 11 - "time" 12 - ) 13 - 14 - // Session represents a user session 15 - type Session struct { 16 - ID string 17 - DID string 18 - Handle string 19 - PDSEndpoint string 20 - OAuthSessionID string // Store OAuth sessionID for resuming 21 - ExpiresAt time.Time 22 - } 23 - 24 - // Store manages user sessions 25 - type Store struct { 26 - mu sync.RWMutex 27 - sessions map[string]*Session 28 - filePath string 29 - } 30 - 31 - // NewStore creates a new session store with file persistence 32 - func NewStore(filePath string) *Store { 33 - store := &Store{ 34 - sessions: make(map[string]*Session), 35 - filePath: filePath, 36 - } 37 - 38 - // Load existing sessions from file 39 - if err := store.load(); err != nil { 40 - fmt.Printf("Warning: Failed to load sessions from %s: %v\n", filePath, err) 41 - } 42 - 43 - return store 44 - } 45 - 46 - // load reads sessions from disk 47 - func (s *Store) load() error { 48 - if s.filePath == "" { 49 - return nil 50 - } 51 - 52 - data, err := os.ReadFile(s.filePath) 53 - if err != nil { 54 - if os.IsNotExist(err) { 55 - return nil // File doesn't exist yet, that's fine 56 - } 57 - return err 58 - } 59 - 60 - var sessions map[string]*Session 61 - if err := json.Unmarshal(data, &sessions); err != nil { 62 - return err 63 - } 64 - 65 - // Filter out expired sessions 66 - now := time.Now() 67 - for id, sess := range sessions { 68 - if now.Before(sess.ExpiresAt) { 69 - s.sessions[id] = sess 70 - } 71 - } 72 - 73 - fmt.Printf("Loaded %d active sessions from disk\n", len(s.sessions)) 74 - return nil 75 - } 76 - 77 - // save writes sessions to disk 78 - func (s *Store) save() error { 79 - if s.filePath == "" { 80 - return nil 81 - } 82 - 83 - data, err := json.Marshal(s.sessions) 84 - if err != nil { 85 - return err 86 - } 87 - 88 - return os.WriteFile(s.filePath, data, 0600) 89 - } 90 - 91 - // Create creates a new session and returns the session ID 92 - func (s *Store) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) { 93 - return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration) 94 - } 95 - 96 - // CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID 97 - func (s *Store) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) { 98 - s.mu.Lock() 99 - defer s.mu.Unlock() 100 - 101 - // Generate random session ID 102 - b := make([]byte, 32) 103 - if _, err := rand.Read(b); err != nil { 104 - return "", err 105 - } 106 - 107 - sess := &Session{ 108 - ID: base64.URLEncoding.EncodeToString(b), 109 - DID: did, 110 - Handle: handle, 111 - PDSEndpoint: pdsEndpoint, 112 - OAuthSessionID: oauthSessionID, 113 - ExpiresAt: time.Now().Add(duration), 114 - } 115 - 116 - s.sessions[sess.ID] = sess 117 - 118 - // Save to disk 119 - if err := s.save(); err != nil { 120 - fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err) 121 - } 122 - 123 - return sess.ID, nil 124 - } 125 - 126 - // Get retrieves a session by ID 127 - func (s *Store) Get(id string) (*Session, bool) { 128 - s.mu.RLock() 129 - defer s.mu.RUnlock() 130 - 131 - sess, ok := s.sessions[id] 132 - if !ok || time.Now().After(sess.ExpiresAt) { 133 - return nil, false 134 - } 135 - 136 - return sess, true 137 - } 138 - 139 - // Extend extends a session's expiration time 140 - func (s *Store) Extend(id string, duration time.Duration) error { 141 - s.mu.Lock() 142 - defer s.mu.Unlock() 143 - 144 - sess, ok := s.sessions[id] 145 - if !ok { 146 - return fmt.Errorf("session not found: %s", id) 147 - } 148 - 149 - // Extend the expiration 150 - sess.ExpiresAt = time.Now().Add(duration) 151 - 152 - // Save to disk 153 - if err := s.save(); err != nil { 154 - fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err) 155 - } 156 - 157 - return nil 158 - } 159 - 160 - // Delete removes a session 161 - func (s *Store) Delete(id string) { 162 - s.mu.Lock() 163 - defer s.mu.Unlock() 164 - 165 - delete(s.sessions, id) 166 - 167 - // Save to disk 168 - if err := s.save(); err != nil { 169 - fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err) 170 - } 171 - } 172 - 173 - // Cleanup removes expired sessions 174 - func (s *Store) Cleanup() { 175 - s.mu.Lock() 176 - defer s.mu.Unlock() 177 - 178 - now := time.Now() 179 - deleted := 0 180 - for id, sess := range s.sessions { 181 - if now.After(sess.ExpiresAt) { 182 - delete(s.sessions, id) 183 - deleted++ 184 - } 185 - } 186 - 187 - if deleted > 0 { 188 - // Save to disk 189 - if err := s.save(); err != nil { 190 - fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err) 191 - } 192 - } 193 - } 194 - 195 - // SetCookie sets the session cookie 196 - func SetCookie(w http.ResponseWriter, sessionID string, maxAge int) { 197 - http.SetCookie(w, &http.Cookie{ 198 - Name: "atcr_session", 199 - Value: sessionID, 200 - Path: "/", 201 - MaxAge: maxAge, 202 - HttpOnly: true, 203 - Secure: true, 204 - SameSite: http.SameSiteLaxMode, 205 - }) 206 - } 207 - 208 - // ClearCookie clears the session cookie 209 - func ClearCookie(w http.ResponseWriter) { 210 - http.SetCookie(w, &http.Cookie{ 211 - Name: "atcr_session", 212 - Value: "", 213 - Path: "/", 214 - MaxAge: -1, 215 - HttpOnly: true, 216 - Secure: true, 217 - SameSite: http.SameSiteLaxMode, 218 - }) 219 - } 220 - 221 - // GetSessionID gets session ID from cookie 222 - func GetSessionID(r *http.Request) (string, bool) { 223 - cookie, err := r.Cookie("atcr_session") 224 - if err != nil { 225 - return "", false 226 - } 227 - return cookie.Value, true 228 - }
+221
pkg/appview/static/css/style.css
··· 293 293 294 294 .push-repo { 295 295 font-weight: 500; 296 + color: var(--fg); 297 + text-decoration: none; 298 + } 299 + 300 + .push-repo:hover { 301 + color: var(--primary); 302 + text-decoration: underline; 296 303 } 297 304 298 305 .push-tag { ··· 375 382 .repo-header h2 { 376 383 font-size: 1.3rem; 377 384 margin: 0; 385 + } 386 + 387 + .repo-title-link { 388 + color: var(--fg); 389 + text-decoration: none; 390 + } 391 + 392 + .repo-title-link:hover { 393 + color: var(--primary); 394 + text-decoration: underline; 378 395 } 379 396 380 397 .repo-badge { ··· 710 727 text-decoration: underline; 711 728 } 712 729 730 + /* Repository Page */ 731 + .repository-page { 732 + max-width: 1000px; 733 + margin: 0 auto; 734 + } 735 + 736 + .repository-header { 737 + background:var(--bg); 738 + border: 1px solid var(--border); 739 + border-radius: 8px; 740 + padding: 2rem; 741 + margin-bottom: 2rem; 742 + box-shadow: 0 1px 3px rgba(0,0,0,0.05); 743 + } 744 + 745 + .repo-hero { 746 + display: flex; 747 + gap: 1.5rem; 748 + align-items: flex-start; 749 + margin-bottom: 1.5rem; 750 + } 751 + 752 + .repo-hero-icon { 753 + width: 80px; 754 + height: 80px; 755 + border-radius: 12px; 756 + object-fit: cover; 757 + flex-shrink: 0; 758 + } 759 + 760 + .repo-hero-icon-placeholder { 761 + width: 80px; 762 + height: 80px; 763 + border-radius: 12px; 764 + background: var(--primary); 765 + display: flex; 766 + align-items: center; 767 + justify-content: center; 768 + font-weight: bold; 769 + font-size: 2.5rem; 770 + text-transform: uppercase; 771 + color: white; 772 + flex-shrink: 0; 773 + } 774 + 775 + .repo-hero-info { 776 + flex: 1; 777 + } 778 + 779 + .repo-hero-info h1 { 780 + font-size: 2rem; 781 + margin: 0 0 0.5rem 0; 782 + } 783 + 784 + .owner-link { 785 + color: var(--primary); 786 + text-decoration: none; 787 + } 788 + 789 + .owner-link:hover { 790 + text-decoration: underline; 791 + } 792 + 793 + .repo-separator { 794 + color: #999; 795 + margin: 0 0.25rem; 796 + } 797 + 798 + .repo-name { 799 + color: var(--fg); 800 + } 801 + 802 + .repo-hero-description { 803 + color: #555; 804 + font-size: 1.1rem; 805 + line-height: 1.5; 806 + margin: 0.5rem 0 0 0; 807 + } 808 + 809 + .repo-metadata { 810 + display: flex; 811 + gap: 1rem; 812 + align-items: center; 813 + flex-wrap: wrap; 814 + margin-bottom: 1.5rem; 815 + padding-top: 1rem; 816 + border-top: 1px solid var(--border); 817 + } 818 + 819 + .metadata-badge { 820 + display: inline-flex; 821 + align-items: center; 822 + padding: 0.3rem 0.75rem; 823 + font-size: 0.85rem; 824 + font-weight: 500; 825 + border-radius: 16px; 826 + white-space: nowrap; 827 + } 828 + 829 + .metadata-link { 830 + color: var(--primary); 831 + text-decoration: none; 832 + font-weight: 500; 833 + } 834 + 835 + .metadata-link:hover { 836 + text-decoration: underline; 837 + } 838 + 839 + .pull-command-section { 840 + padding-top: 1rem; 841 + border-top: 1px solid var(--border); 842 + } 843 + 844 + .pull-command-section h3 { 845 + font-size: 1rem; 846 + margin-bottom: 0.75rem; 847 + color: var(--secondary); 848 + } 849 + 850 + .repo-section { 851 + background:var(--bg); 852 + border: 1px solid var(--border); 853 + border-radius: 8px; 854 + padding: 1.5rem; 855 + margin-bottom: 2rem; 856 + box-shadow: 0 1px 3px rgba(0,0,0,0.05); 857 + } 858 + 859 + .repo-section h2 { 860 + font-size: 1.5rem; 861 + margin-bottom: 1rem; 862 + padding-bottom: 0.5rem; 863 + border-bottom: 2px solid var(--border); 864 + } 865 + 866 + .tags-list, .manifests-list { 867 + display: flex; 868 + flex-direction: column; 869 + gap: 1rem; 870 + } 871 + 872 + .tag-item, .manifest-item { 873 + border: 1px solid var(--border); 874 + border-radius: 6px; 875 + padding: 1rem; 876 + background: var(--hover-bg); 877 + } 878 + 879 + .tag-item-header, .manifest-item-header { 880 + display: flex; 881 + justify-content: space-between; 882 + align-items: center; 883 + margin-bottom: 0.5rem; 884 + } 885 + 886 + .tag-name-large { 887 + font-size: 1.2rem; 888 + font-weight: 600; 889 + color: var(--primary); 890 + } 891 + 892 + .tag-timestamp { 893 + color: #666; 894 + font-size: 0.9rem; 895 + } 896 + 897 + .tag-item-details { 898 + margin-bottom: 0.75rem; 899 + } 900 + 901 + .manifest-item-details { 902 + display: flex; 903 + gap: 0.5rem; 904 + align-items: center; 905 + color: #666; 906 + font-size: 0.9rem; 907 + margin-top: 0.5rem; 908 + } 909 + 910 + .manifest-detail-label { 911 + font-weight: 500; 912 + color: var(--secondary); 913 + } 914 + 713 915 /* Responsive */ 714 916 @media (max-width: 768px) { 715 917 .navbar { ··· 733 935 .login-page { 734 936 margin: 2rem auto; 735 937 padding: 1rem; 938 + } 939 + 940 + .repo-hero { 941 + flex-direction: column; 942 + } 943 + 944 + .repo-hero-info h1 { 945 + font-size: 1.5rem; 946 + } 947 + 948 + .tag-item-header { 949 + flex-direction: column; 950 + align-items: flex-start; 951 + gap: 0.5rem; 952 + } 953 + 954 + .manifest-item-details { 955 + flex-direction: column; 956 + align-items: flex-start; 736 957 } 737 958 }
+3 -3
pkg/appview/templates/pages/images.html
··· 20 20 {{ range .Repositories }} 21 21 {{ $repoName := .Name }} 22 22 <div class="repository-card"> 23 - <div class="repo-header" onclick="toggleRepo('{{ $repoName }}')"> 23 + <div class="repo-header"> 24 24 {{ if .IconURL }} 25 25 <img src="{{ .IconURL }}" alt="{{ $repoName }}" class="repo-icon"> 26 26 {{ end }} 27 27 <div class="repo-info"> 28 28 <div class="repo-title-row"> 29 - <h2>{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</h2> 29 + <h2><a href="/r/{{ $.User.Handle }}/{{ $repoName }}" class="repo-title-link">{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</a></h2> 30 30 {{ if .Licenses }} 31 31 <span class="repo-badge license-badge">{{ .Licenses }}</span> 32 32 {{ end }} ··· 52 52 {{ end }} 53 53 </div> 54 54 </div> 55 - <button class="expand-btn" id="btn-{{ $repoName }}">▼</button> 55 + <button class="expand-btn" id="btn-{{ $repoName }}" onclick="toggleRepo('{{ $repoName }}'); event.stopPropagation();">▼</button> 56 56 </div> 57 57 58 58 <div id="repo-{{ $repoName }}" class="repo-details" style="display: none;">
+139
pkg/appview/templates/pages/repository.html
··· 1 + {{ define "repository" }} 2 + <!DOCTYPE html> 3 + <html lang="en"> 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title> 8 + <link rel="stylesheet" href="/static/css/style.css"> 9 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 10 + <script src="/static/js/app.js"></script> 11 + </head> 12 + <body> 13 + {{ template "nav" . }} 14 + 15 + <main class="container"> 16 + <div class="repository-page"> 17 + <!-- Repository Header --> 18 + <div class="repository-header"> 19 + <div class="repo-hero"> 20 + {{ if .Repository.IconURL }} 21 + <img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon"> 22 + {{ else }} 23 + <div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div> 24 + {{ end }} 25 + <div class="repo-hero-info"> 26 + <h1> 27 + <a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a> 28 + <span class="repo-separator">/</span> 29 + <span class="repo-name">{{ .Repository.Name }}</span> 30 + </h1> 31 + {{ if .Repository.Description }} 32 + <p class="repo-hero-description">{{ .Repository.Description }}</p> 33 + {{ end }} 34 + </div> 35 + </div> 36 + 37 + <!-- Metadata Section --> 38 + {{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL }} 39 + <div class="repo-metadata"> 40 + {{ if .Repository.Licenses }} 41 + <span class="metadata-badge license-badge">{{ .Repository.Licenses }}</span> 42 + {{ end }} 43 + {{ if .Repository.SourceURL }} 44 + <a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link"> 45 + Source 46 + </a> 47 + {{ end }} 48 + {{ if .Repository.DocumentationURL }} 49 + <a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link"> 50 + Documentation 51 + </a> 52 + {{ end }} 53 + </div> 54 + {{ end }} 55 + 56 + <!-- Pull Command --> 57 + <div class="pull-command-section"> 58 + <h3>Pull this image</h3> 59 + {{ if .Repository.Tags }} 60 + {{ $firstTag := index .Repository.Tags 0 }} 61 + <div class="push-command"> 62 + <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}</code> 63 + <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag }}')"> 64 + Copy 65 + </button> 66 + </div> 67 + {{ else }} 68 + <div class="push-command"> 69 + <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest</code> 70 + <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest')"> 71 + Copy 72 + </button> 73 + </div> 74 + {{ end }} 75 + </div> 76 + </div> 77 + 78 + <!-- Tags Section --> 79 + <div class="repo-section"> 80 + <h2>Tags</h2> 81 + {{ if .Repository.Tags }} 82 + <div class="tags-list"> 83 + {{ range .Repository.Tags }} 84 + <div class="tag-item"> 85 + <div class="tag-item-header"> 86 + <span class="tag-name-large">{{ .Tag }}</span> 87 + <time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 88 + {{ timeAgo .CreatedAt }} 89 + </time> 90 + </div> 91 + <div class="tag-item-details"> 92 + <code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code> 93 + </div> 94 + <div class="push-command"> 95 + <code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}</code> 96 + <button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag }}')"> 97 + Copy 98 + </button> 99 + </div> 100 + </div> 101 + {{ end }} 102 + </div> 103 + {{ else }} 104 + <p class="empty-message">No tags available</p> 105 + {{ end }} 106 + </div> 107 + 108 + <!-- Manifests Section --> 109 + <div class="repo-section"> 110 + <h2>Manifests</h2> 111 + {{ if .Repository.Manifests }} 112 + <div class="manifests-list"> 113 + {{ range .Repository.Manifests }} 114 + <div class="manifest-item"> 115 + <div class="manifest-item-header"> 116 + <code class="manifest-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 16 }}</code> 117 + <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 118 + {{ timeAgo .CreatedAt }} 119 + </time> 120 + </div> 121 + <div class="manifest-item-details"> 122 + <span class="manifest-detail-label">Storage:</span> 123 + <span>{{ .HoldEndpoint }}</span> 124 + </div> 125 + </div> 126 + {{ end }} 127 + </div> 128 + {{ else }} 129 + <p class="empty-message">No manifests available</p> 130 + {{ end }} 131 + </div> 132 + </div> 133 + </main> 134 + 135 + <!-- Modal container for HTMX --> 136 + <div id="modal"></div> 137 + </body> 138 + </html> 139 + {{ end }}
+1 -1
pkg/appview/templates/pages/user.html
··· 27 27 {{ range .Pushes }} 28 28 <div class="push-card"> 29 29 <div class="push-header"> 30 - <span class="push-repo">{{ .Repository }}</span> 30 + <a href="/r/{{ $.ViewedUser.Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a> 31 31 <span class="push-separator">:</span> 32 32 <span class="push-tag">{{ .Tag }}</span> 33 33 </div>
+1 -1
pkg/appview/templates/partials/push-list.html
··· 3 3 <div class="push-header"> 4 4 <a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a> 5 5 <span class="push-separator">/</span> 6 - <span class="push-repo">{{ .Repository }}</span> 6 + <a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a> 7 7 <span class="push-separator">:</span> 8 8 <span class="push-tag">{{ .Tag }}</span> 9 9 </div>
+9 -13
pkg/auth/oauth/refresher.go
··· 81 81 return nil, fmt.Errorf("failed to parse DID: %w", err) 82 82 } 83 83 84 - // Get all sessions for this DID from store 85 - fileStore, ok := r.app.clientApp.Store.(*FileStore) 86 - if !ok { 87 - return nil, fmt.Errorf("store is not a FileStore") 84 + // Get the latest session for this DID from SQLite store 85 + // The store must implement GetLatestSessionForDID (returns newest by updated_at) 86 + type sessionGetter interface { 87 + GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) 88 88 } 89 89 90 - // Find a session for this DID 91 - sessions := fileStore.ListSessions() 92 - var sessionID string 93 - for _, sessionData := range sessions { 94 - if sessionData.AccountDID.String() == did { 95 - sessionID = sessionData.SessionID 96 - break 97 - } 90 + getter, ok := r.app.clientApp.Store.(sessionGetter) 91 + if !ok { 92 + return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)") 98 93 } 99 94 100 - if sessionID == "" { 95 + _, sessionID, err := getter.GetLatestSessionForDID(ctx, did) 96 + if err != nil { 101 97 return nil, fmt.Errorf("no session found for DID: %s", did) 102 98 } 103 99
+3 -3
pkg/auth/token/handler.go
··· 10 10 "github.com/bluesky-social/indigo/atproto/identity" 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 13 - "atcr.io/pkg/appview/device" 13 + "atcr.io/pkg/appview/db" 14 14 mainAtproto "atcr.io/pkg/atproto" 15 15 "atcr.io/pkg/auth" 16 16 "atcr.io/pkg/auth/atproto" ··· 20 20 type Handler struct { 21 21 issuer *Issuer 22 22 validator *atproto.SessionValidator 23 - deviceStore *device.Store // For validating device secrets 23 + deviceStore *db.DeviceStore // For validating device secrets 24 24 defaultHoldEndpoint string 25 25 } 26 26 27 27 // NewHandler creates a new token handler 28 - func NewHandler(issuer *Issuer, deviceStore *device.Store, defaultHoldEndpoint string) *Handler { 28 + func NewHandler(issuer *Issuer, deviceStore *db.DeviceStore, defaultHoldEndpoint string) *Handler { 29 29 return &Handler{ 30 30 issuer: issuer, 31 31 validator: atproto.NewSessionValidator(),