A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

move file store cache to sqlite. implement repository page

+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(),