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.

update docker credential helper to remove configure and store devices, not api keys. improve ui, fetch profile image

+2011 -721
+166 -97
cmd/credential-helper/main.go
··· 1 1 package main 2 2 3 3 import ( 4 + "bytes" 4 5 "encoding/json" 5 6 "fmt" 7 + "io" 8 + "net/http" 6 9 "os" 10 + "os/exec" 7 11 "path/filepath" 8 - "strings" 9 - 10 - "atcr.io/pkg/auth/oauth" 12 + "runtime" 13 + "time" 11 14 ) 12 15 13 16 const ( ··· 15 18 defaultAppViewURL = "http://127.0.0.1:5000" 16 19 ) 17 20 18 - // CredentialStore represents the stored API key credentials 19 - type CredentialStore struct { 20 - APIKey string `json:"api_key"` 21 - Handle string `json:"handle"` 22 - AppViewURL string `json:"appview_url"` 21 + // DeviceConfig represents the stored device configuration 22 + type DeviceConfig struct { 23 + Handle string `json:"handle"` 24 + DeviceSecret string `json:"device_secret"` 25 + AppViewURL string `json:"appview_url"` 23 26 } 24 27 25 28 // Docker credential helper protocol ··· 32 35 Secret string `json:"Secret,omitempty"` 33 36 } 34 37 38 + // Device authorization API types 39 + 40 + type DeviceCodeRequest struct { 41 + DeviceName string `json:"device_name"` 42 + } 43 + 44 + type DeviceCodeResponse struct { 45 + DeviceCode string `json:"device_code"` 46 + UserCode string `json:"user_code"` 47 + VerificationURI string `json:"verification_uri"` 48 + ExpiresIn int `json:"expires_in"` 49 + Interval int `json:"interval"` 50 + } 51 + 52 + type DeviceTokenRequest struct { 53 + DeviceCode string `json:"device_code"` 54 + } 55 + 56 + type DeviceTokenResponse struct { 57 + DeviceSecret string `json:"device_secret,omitempty"` 58 + Handle string `json:"handle,omitempty"` 59 + DID string `json:"did,omitempty"` 60 + Error string `json:"error,omitempty"` 61 + } 62 + 35 63 func main() { 36 64 if len(os.Args) < 2 { 37 - fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure [handle]>\n") 65 + fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase>\n") 38 66 os.Exit(1) 39 67 } 40 68 ··· 47 75 handleStore() 48 76 case "erase": 49 77 handleErase() 50 - case "configure": 51 - // Optional handle argument 52 - var handle string 53 - if len(os.Args) > 2 { 54 - handle = os.Args[2] 55 - } 56 - handleConfigure(handle) 57 78 default: 58 79 fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) 59 80 os.Exit(1) ··· 69 90 os.Exit(1) 70 91 } 71 92 72 - // Load credentials from storage 73 - credsPath := getCredentialsPath() 74 - storedCreds, err := loadCredentials(credsPath) 75 - if err != nil { 76 - fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err) 77 - fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n") 78 - os.Exit(1) 93 + // Load device configuration 94 + configPath := getConfigPath() 95 + deviceConfig, err := loadDeviceConfig(configPath) 96 + if err != nil || deviceConfig.DeviceSecret == "" { 97 + // First time - trigger device authorization 98 + fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n") 99 + 100 + deviceConfig, err = authorizeDevice() 101 + if err != nil { 102 + fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err) 103 + fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login atcr.io' with your ATProto app-password\n") 104 + os.Exit(1) 105 + } 106 + 107 + // Save device configuration 108 + if err := saveDeviceConfig(configPath, deviceConfig); err != nil { 109 + fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err) 110 + os.Exit(1) 111 + } 112 + 113 + fmt.Fprintf(os.Stderr, "✓ Device authorized successfully!\n") 79 114 } 80 115 81 116 // Return credentials for Docker 82 - // Docker will send these as Basic Auth to /auth/token 83 - // The token handler will validate the API key and issue a registry JWT 84 117 creds := Credentials{ 85 118 ServerURL: serverURL, 86 - Username: storedCreds.Handle, // Use handle as username 87 - Secret: storedCreds.APIKey, // API key as password 119 + Username: deviceConfig.Handle, 120 + Secret: deviceConfig.DeviceSecret, 88 121 } 89 122 90 123 if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { ··· 101 134 os.Exit(1) 102 135 } 103 136 104 - // For OAuth flow, we don't actually store credentials from docker login 105 - // The credentials are managed through the OAuth flow 106 - // This is a no-op for us 137 + // This is a no-op for the device auth flow 138 + // Users should use the automatic device authorization, not docker login 139 + // If they use docker login with app-password, that goes through /auth/token directly 107 140 } 108 141 109 142 // handleErase removes stored credentials ··· 115 148 os.Exit(1) 116 149 } 117 150 118 - // Remove credentials file 119 - credsPath := getCredentialsPath() 120 - if err := os.Remove(credsPath); err != nil && !os.IsNotExist(err) { 121 - fmt.Fprintf(os.Stderr, "Error removing credentials: %v\n", err) 151 + // Remove device configuration file 152 + configPath := getConfigPath() 153 + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { 154 + fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err) 122 155 os.Exit(1) 123 156 } 124 157 } 125 158 126 - // handleConfigure prompts for API key and saves credentials 127 - func handleConfigure(handle string) { 128 - fmt.Println("ATCR Credential Helper Configuration") 129 - fmt.Println("=====================================") 130 - fmt.Println() 131 - fmt.Println("You need an API key from the ATCR web UI.") 132 - fmt.Println() 133 - 134 - // Get AppView URL from environment or use default 159 + // authorizeDevice performs the device authorization flow 160 + func authorizeDevice() (*DeviceConfig, error) { 161 + // Get AppView URL 135 162 appViewURL := os.Getenv("ATCR_APPVIEW_URL") 136 163 if appViewURL == "" { 137 164 appViewURL = defaultAppViewURL 138 165 } 139 166 140 - // Auto-open settings page 141 - settingsURL := appViewURL + "/settings" 142 - fmt.Printf("Opening settings page: %s\n", settingsURL) 143 - fmt.Println("Log in and generate an API key if you haven't already.") 144 - fmt.Println() 167 + // Get device name (hostname) 168 + deviceName, err := os.Hostname() 169 + if err != nil { 170 + deviceName = "Unknown Device" 171 + } 145 172 146 - if err := oauth.OpenBrowser(settingsURL); err != nil { 147 - fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL) 173 + // 1. Request device code 174 + fmt.Fprintf(os.Stderr, "Requesting device authorization...\n") 175 + 176 + reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName}) 177 + resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody)) 178 + if err != nil { 179 + return nil, fmt.Errorf("failed to request device code: %w", err) 148 180 } 181 + defer resp.Body.Close() 149 182 150 - // Prompt for credentials 151 - if handle == "" { 152 - fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") 153 - if _, err := fmt.Scanln(&handle); err != nil { 154 - fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) 155 - os.Exit(1) 156 - } 157 - } else { 158 - fmt.Printf("Using handle: %s\n", handle) 183 + if resp.StatusCode != http.StatusOK { 184 + body, _ := io.ReadAll(resp.Body) 185 + return nil, fmt.Errorf("device code request failed: %s", string(body)) 159 186 } 160 187 161 - fmt.Print("Enter your API key (from settings page): ") 162 - var apiKey string 163 - if _, err := fmt.Scanln(&apiKey); err != nil { 164 - fmt.Fprintf(os.Stderr, "Error reading API key: %v\n", err) 165 - os.Exit(1) 188 + var codeResp DeviceCodeResponse 189 + if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { 190 + return nil, fmt.Errorf("failed to decode device code response: %w", err) 166 191 } 167 192 168 - // Validate key format 169 - if !strings.HasPrefix(apiKey, "atcr_") { 170 - fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n") 171 - os.Exit(1) 193 + // 2. Open browser for user to approve 194 + verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode 195 + 196 + fmt.Fprintf(os.Stderr, "\nOpening browser for device authorization...\n") 197 + fmt.Fprintf(os.Stderr, "User code: %s\n", codeResp.UserCode) 198 + fmt.Fprintf(os.Stderr, "\nIf browser doesn't open, visit: %s\n\n", verificationURL) 199 + 200 + if err := openBrowser(verificationURL); err != nil { 201 + fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err) 172 202 } 173 203 174 - // Save credentials 175 - creds := &CredentialStore{ 176 - Handle: handle, 177 - APIKey: apiKey, 178 - AppViewURL: appViewURL, 179 - } 204 + fmt.Fprintf(os.Stderr, "Waiting for authorization...\n") 205 + 206 + // 3. Poll for authorization completion 207 + pollInterval := time.Duration(codeResp.Interval) * time.Second 208 + timeout := time.Duration(codeResp.ExpiresIn) * time.Second 209 + deadline := time.Now().Add(timeout) 210 + 211 + for time.Now().Before(deadline) { 212 + time.Sleep(pollInterval) 213 + 214 + // Poll token endpoint 215 + tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) 216 + tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) 217 + if err != nil { 218 + fmt.Fprintf(os.Stderr, "Poll failed: %v\n", err) 219 + continue 220 + } 221 + 222 + var tokenResult DeviceTokenResponse 223 + json.NewDecoder(tokenResp.Body).Decode(&tokenResult) 224 + tokenResp.Body.Close() 225 + 226 + if tokenResult.Error == "authorization_pending" { 227 + // Still waiting 228 + continue 229 + } 230 + 231 + if tokenResult.Error != "" { 232 + return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) 233 + } 180 234 181 - if err := saveCredentials(getCredentialsPath(), creds); err != nil { 182 - fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err) 183 - os.Exit(1) 235 + // Success! 236 + return &DeviceConfig{ 237 + Handle: tokenResult.Handle, 238 + DeviceSecret: tokenResult.DeviceSecret, 239 + AppViewURL: appViewURL, 240 + }, nil 184 241 } 185 242 186 - fmt.Println() 187 - fmt.Println("✓ Configuration complete!") 188 - fmt.Println("You can now use docker push/pull with atcr.io") 243 + return nil, fmt.Errorf("authorization timeout") 189 244 } 190 245 191 - // getCredentialsPath returns the path to the credentials file 192 - func getCredentialsPath() string { 246 + // getConfigPath returns the path to the device configuration file 247 + func getConfigPath() string { 193 248 homeDir, err := os.UserHomeDir() 194 249 if err != nil { 195 250 fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) ··· 202 257 os.Exit(1) 203 258 } 204 259 205 - return filepath.Join(atcrDir, "credentials.json") 260 + return filepath.Join(atcrDir, "device.json") 206 261 } 207 262 208 - // loadCredentials loads the credentials from disk 209 - func loadCredentials(path string) (*CredentialStore, error) { 263 + // loadDeviceConfig loads the device configuration from disk 264 + func loadDeviceConfig(path string) (*DeviceConfig, error) { 210 265 data, err := os.ReadFile(path) 211 266 if err != nil { 212 - return nil, fmt.Errorf("failed to read credentials file: %w", err) 267 + return nil, err 213 268 } 214 269 215 - var creds CredentialStore 216 - if err := json.Unmarshal(data, &creds); err != nil { 217 - return nil, fmt.Errorf("failed to parse credentials file: %w", err) 270 + var config DeviceConfig 271 + if err := json.Unmarshal(data, &config); err != nil { 272 + return nil, err 218 273 } 219 274 220 - return &creds, nil 275 + return &config, nil 221 276 } 222 277 223 - // saveCredentials saves the credentials to disk 224 - func saveCredentials(path string, creds *CredentialStore) error { 225 - data, err := json.MarshalIndent(creds, "", " ") 278 + // saveDeviceConfig saves the device configuration to disk 279 + func saveDeviceConfig(path string, config *DeviceConfig) error { 280 + data, err := json.MarshalIndent(config, "", " ") 226 281 if err != nil { 227 - return fmt.Errorf("failed to marshal credentials: %w", err) 282 + return err 228 283 } 229 284 230 - if err := os.WriteFile(path, data, 0600); err != nil { 231 - return fmt.Errorf("failed to write credentials file: %w", err) 285 + return os.WriteFile(path, data, 0600) 286 + } 287 + 288 + // openBrowser opens the specified URL in the default browser 289 + func openBrowser(url string) error { 290 + var cmd *exec.Cmd 291 + 292 + switch runtime.GOOS { 293 + case "linux": 294 + cmd = exec.Command("xdg-open", url) 295 + case "darwin": 296 + cmd = exec.Command("open", url) 297 + case "windows": 298 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 299 + default: 300 + return fmt.Errorf("unsupported platform") 232 301 } 233 302 234 - return nil 303 + return cmd.Start() 235 304 }
+89 -24
cmd/registry/serve.go
··· 23 23 24 24 // UI components 25 25 "atcr.io/pkg/appview" 26 - "atcr.io/pkg/appview/apikey" 27 26 "atcr.io/pkg/appview/db" 27 + "atcr.io/pkg/appview/device" 28 28 uihandlers "atcr.io/pkg/appview/handlers" 29 29 "atcr.io/pkg/appview/jetstream" 30 30 appmiddleware "atcr.io/pkg/appview/middleware" ··· 92 92 return fmt.Errorf("failed to create OAuth store: %w", err) 93 93 } 94 94 95 - // 2. Create API key store 96 - apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json") 97 - apiKeyStore, err := apikey.NewStore(apiKeyStorePath) 95 + // 2. Create device store 96 + deviceStorePath := filepath.Join(filepath.Dir(storagePath), "devices.json") 97 + deviceStore, err := device.NewStore(deviceStorePath) 98 98 if err != nil { 99 - return fmt.Errorf("failed to create API key store: %w", err) 99 + return fmt.Errorf("failed to create device store: %w", err) 100 100 } 101 - fmt.Printf("Using API key storage path: %s\n", apiKeyStorePath) 101 + fmt.Printf("Using device storage path: %s\n", deviceStorePath) 102 + 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 + }() 102 111 103 112 // 3. Get base URL from config or environment 104 113 baseURL := os.Getenv("ATCR_BASE_URL") ··· 127 136 middleware.SetGlobalRefresher(refresher) 128 137 129 138 // 7. Initialize UI components (get session store for OAuth integration) 130 - uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL, apiKeyStore) 139 + uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, oauthApp, refresher, baseURL, deviceStore) 131 140 132 141 // 8. Create OAuth server 133 142 oauthServer := oauth.NewServer(oauthApp) ··· 136 145 // Connect UI session store for web login 137 146 if uiSessionStore != nil { 138 147 oauthServer.SetUISessionStore(uiSessionStore) 148 + } 149 + // Connect database for user avatar management 150 + if uiDatabase != nil { 151 + oauthServer.SetDatabase(uiDatabase) 139 152 } 140 153 141 154 // 8. Initialize auth keys and create token issuer ··· 187 200 // Extract default hold endpoint from middleware config 188 201 defaultHoldEndpoint := extractDefaultHoldEndpoint(config) 189 202 190 - // Basic Auth token endpoint (supports API keys and app passwords) 191 - tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint) 203 + // Basic Auth token endpoint (supports device secrets and app passwords) 204 + tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldEndpoint) 192 205 tokenHandler.RegisterRoutes(mux) 193 206 207 + // Device authorization endpoints (public) 208 + mux.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{ 209 + Store: deviceStore, 210 + AppViewBaseURL: baseURL, 211 + }) 212 + mux.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{ 213 + Store: deviceStore, 214 + }) 215 + 194 216 fmt.Printf("Auth endpoints enabled:\n") 195 - fmt.Printf(" - Basic Auth: /auth/token (API keys + app passwords)\n") 217 + fmt.Printf(" - Basic Auth: /auth/token (device secrets + app passwords)\n") 218 + fmt.Printf(" - Device Auth: /auth/device/code\n") 219 + fmt.Printf(" - Device Auth: /auth/device/token\n") 196 220 fmt.Printf(" - OAuth: /auth/oauth/authorize\n") 197 221 fmt.Printf(" - OAuth: /auth/oauth/callback\n") 198 222 } ··· 326 350 } 327 351 328 352 // initializeUI initializes the web UI components 329 - func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string, apiKeyStore *apikey.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { 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) { 330 354 // Check if UI is enabled (optional configuration) 331 355 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 332 356 if uiEnabled == "false" { ··· 386 410 Templates: templates, 387 411 }).Methods("GET") 388 412 389 - router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{}).Methods("POST") 413 + router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{ 414 + Refresher: refresher, 415 + Directory: oauthApp.Directory(), 416 + SessionStore: sessionStore, 417 + }).Methods("POST") 390 418 391 419 // Public routes (with optional auth for navbar) 392 - router.Handle("/", appmiddleware.OptionalAuth(sessionStore)( 420 + router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)( 393 421 &uihandlers.HomeHandler{ 394 422 DB: database, 395 423 Templates: templates, ··· 397 425 }, 398 426 )).Methods("GET") 399 427 400 - router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore)( 428 + router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)( 401 429 &uihandlers.RecentPushesHandler{ 402 430 DB: database, 403 431 Templates: templates, ··· 407 435 408 436 // Authenticated routes 409 437 authRouter := router.NewRoute().Subrouter() 410 - authRouter.Use(appmiddleware.RequireAuth(sessionStore)) 438 + authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) 411 439 412 440 authRouter.Handle("/images", &uihandlers.ImagesHandler{ 413 441 DB: database, ··· 416 444 }).Methods("GET") 417 445 418 446 authRouter.Handle("/settings", &uihandlers.SettingsHandler{ 419 - Templates: templates, 420 - Refresher: refresher, 447 + Templates: templates, 448 + Refresher: refresher, 449 + RegistryURL: baseURL, 421 450 }).Methods("GET") 422 451 423 452 authRouter.Handle("/api/profile/default-hold", &uihandlers.UpdateDefaultHoldHandler{ ··· 432 461 DB: database, 433 462 }).Methods("DELETE") 434 463 435 - // API key management routes 436 - authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{ 437 - Store: apiKeyStore, 464 + // Device approval page (authenticated) 465 + authRouter.Handle("/device", &uihandlers.DeviceApprovalPageHandler{ 466 + Store: deviceStore, 467 + SessionStore: sessionStore, 468 + }).Methods("GET") 469 + 470 + authRouter.Handle("/device/approve", &uihandlers.DeviceApproveHandler{ 471 + Store: deviceStore, 472 + SessionStore: sessionStore, 438 473 }).Methods("POST") 439 474 440 - authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{ 441 - Store: apiKeyStore, 475 + // Device management routes 476 + authRouter.Handle("/api/devices", &uihandlers.ListDevicesHandler{ 477 + Store: deviceStore, 478 + SessionStore: sessionStore, 442 479 }).Methods("GET") 443 480 444 - authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{ 445 - Store: apiKeyStore, 481 + authRouter.Handle("/api/devices/{id}", &uihandlers.RevokeDeviceHandler{ 482 + Store: deviceStore, 483 + SessionStore: sessionStore, 446 484 }).Methods("DELETE") 447 485 448 486 // Logout endpoint ··· 484 522 if err != nil { 485 523 fmt.Printf("Warning: Failed to create backfill worker: %v\n", err) 486 524 } else { 525 + // Run initial backfill 487 526 go func() { 488 527 fmt.Printf("Backfill: Starting sync-based backfill from %s...\n", relayEndpoint) 489 528 if err := backfillWorker.Start(context.Background()); err != nil { ··· 492 531 fmt.Println("Backfill: Completed successfully!") 493 532 } 494 533 }() 534 + 535 + // Start periodic backfill scheduler 536 + backfillInterval := os.Getenv("ATCR_BACKFILL_INTERVAL") 537 + if backfillInterval == "" { 538 + backfillInterval = "1h" // Default to 1 hour 539 + } 540 + interval, err := time.ParseDuration(backfillInterval) 541 + if err != nil { 542 + fmt.Printf("Warning: Invalid ATCR_BACKFILL_INTERVAL '%s', using default 1h: %v\n", backfillInterval, err) 543 + interval = time.Hour 544 + } 545 + 546 + go func() { 547 + ticker := time.NewTicker(interval) 548 + defer ticker.Stop() 549 + 550 + for range ticker.C { 551 + fmt.Printf("Backfill: Starting periodic backfill (runs every %s)...\n", interval) 552 + if err := backfillWorker.Start(context.Background()); err != nil { 553 + fmt.Printf("Backfill: Periodic backfill finished with error: %v\n", err) 554 + } else { 555 + fmt.Println("Backfill: Periodic backfill completed successfully!") 556 + } 557 + } 558 + }() 559 + fmt.Printf("Backfill: Periodic scheduler started (interval: %s)\n", interval) 495 560 } 496 561 } 497 562
+6
docker-compose.yml
··· 19 19 # UI database (firehose cache for web interface) 20 20 - atcr-ui:/var/lib/atcr 21 21 restart: unless-stopped 22 + dns: 23 + - 8.8.8.8 24 + - 1.1.1.1 22 25 networks: 23 26 atcr-network: 24 27 ipv4_address: 172.28.0.2 ··· 46 49 volumes: 47 50 - atcr-hold:/var/lib/atcr/hold 48 51 restart: unless-stopped 52 + dns: 53 + - 8.8.8.8 54 + - 1.1.1.1 49 55 networks: 50 56 atcr-network: 51 57 ipv4_address: 172.28.0.3
-249
pkg/appview/apikey/store.go
··· 1 - package apikey 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 - // APIKey represents a user's API key 17 - type APIKey struct { 18 - ID string `json:"id"` // UUID 19 - KeyHash string `json:"key_hash"` // bcrypt hash 20 - DID string `json:"did"` // Owner's DID 21 - Handle string `json:"handle"` // Owner's handle 22 - Name string `json:"name"` // User-provided name 23 - CreatedAt time.Time `json:"created_at"` 24 - LastUsed time.Time `json:"last_used"` 25 - } 26 - 27 - // Store manages API keys 28 - type Store struct { 29 - mu sync.RWMutex 30 - keys map[string]*APIKey // keyHash -> APIKey 31 - byDID map[string][]string // DID -> []keyHash 32 - filePath string // /var/lib/atcr/api-keys.json 33 - } 34 - 35 - // persistentData is the structure saved to disk 36 - type persistentData struct { 37 - Keys []*APIKey `json:"keys"` 38 - } 39 - 40 - // NewStore creates a new API key store 41 - func NewStore(filePath string) (*Store, error) { 42 - s := &Store{ 43 - keys: make(map[string]*APIKey), 44 - byDID: make(map[string][]string), 45 - filePath: filePath, 46 - } 47 - 48 - // Load existing keys from file 49 - if err := s.load(); err != nil && !os.IsNotExist(err) { 50 - return nil, fmt.Errorf("failed to load API keys: %w", err) 51 - } 52 - 53 - return s, nil 54 - } 55 - 56 - // Generate creates a new API key and returns the plaintext key (shown once) 57 - func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error) { 58 - // Generate 32 random bytes 59 - b := make([]byte, 32) 60 - if _, err := rand.Read(b); err != nil { 61 - return "", "", fmt.Errorf("failed to generate random bytes: %w", err) 62 - } 63 - 64 - // Format: atcr_<base64> 65 - key = "atcr_" + base64.RawURLEncoding.EncodeToString(b) 66 - 67 - // Hash for storage 68 - keyHashBytes, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) 69 - if err != nil { 70 - return "", "", fmt.Errorf("failed to hash key: %w", err) 71 - } 72 - keyHash := string(keyHashBytes) 73 - 74 - // Generate ID 75 - keyID = uuid.New().String() 76 - 77 - apiKey := &APIKey{ 78 - ID: keyID, 79 - KeyHash: keyHash, 80 - DID: did, 81 - Handle: handle, 82 - Name: name, 83 - CreatedAt: time.Now(), 84 - LastUsed: time.Time{}, // Never used yet 85 - } 86 - 87 - s.mu.Lock() 88 - s.keys[keyHash] = apiKey 89 - s.byDID[did] = append(s.byDID[did], keyHash) 90 - s.mu.Unlock() 91 - 92 - if err := s.save(); err != nil { 93 - return "", "", fmt.Errorf("failed to save keys: %w", err) 94 - } 95 - 96 - // Return plaintext key (only time it's available) 97 - return key, keyID, nil 98 - } 99 - 100 - // Validate checks if an API key is valid and returns the associated data 101 - func (s *Store) Validate(key string) (*APIKey, error) { 102 - s.mu.RLock() 103 - defer s.mu.RUnlock() 104 - 105 - // Try to match against all stored hashes 106 - for hash, apiKey := range s.keys { 107 - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil { 108 - // Update last used asynchronously 109 - go s.UpdateLastUsed(hash) 110 - 111 - // Return a copy to prevent external modifications 112 - keyCopy := *apiKey 113 - return &keyCopy, nil 114 - } 115 - } 116 - 117 - return nil, fmt.Errorf("invalid API key") 118 - } 119 - 120 - // List returns all API keys for a DID (without plaintext keys) 121 - func (s *Store) List(did string) []*APIKey { 122 - s.mu.RLock() 123 - defer s.mu.RUnlock() 124 - 125 - keyHashes, ok := s.byDID[did] 126 - if !ok { 127 - return []*APIKey{} 128 - } 129 - 130 - result := make([]*APIKey, 0, len(keyHashes)) 131 - for _, hash := range keyHashes { 132 - if apiKey, ok := s.keys[hash]; ok { 133 - // Return copy without hash 134 - keyCopy := *apiKey 135 - keyCopy.KeyHash = "" // Don't expose hash 136 - result = append(result, &keyCopy) 137 - } 138 - } 139 - 140 - return result 141 - } 142 - 143 - // Delete removes an API key 144 - func (s *Store) Delete(did, keyID string) error { 145 - s.mu.Lock() 146 - defer s.mu.Unlock() 147 - 148 - // Find the key by DID and ID 149 - keyHashes, ok := s.byDID[did] 150 - if !ok { 151 - return fmt.Errorf("no keys found for DID: %s", did) 152 - } 153 - 154 - var foundHash string 155 - for _, hash := range keyHashes { 156 - if apiKey, ok := s.keys[hash]; ok && apiKey.ID == keyID { 157 - foundHash = hash 158 - break 159 - } 160 - } 161 - 162 - if foundHash == "" { 163 - return fmt.Errorf("key not found: %s", keyID) 164 - } 165 - 166 - // Remove from keys map 167 - delete(s.keys, foundHash) 168 - 169 - // Remove from byDID index 170 - newHashes := make([]string, 0, len(keyHashes)-1) 171 - for _, hash := range keyHashes { 172 - if hash != foundHash { 173 - newHashes = append(newHashes, hash) 174 - } 175 - } 176 - 177 - if len(newHashes) == 0 { 178 - delete(s.byDID, did) 179 - } else { 180 - s.byDID[did] = newHashes 181 - } 182 - 183 - return s.save() 184 - } 185 - 186 - // UpdateLastUsed updates the last used timestamp 187 - func (s *Store) UpdateLastUsed(keyHash string) error { 188 - s.mu.Lock() 189 - defer s.mu.Unlock() 190 - 191 - apiKey, ok := s.keys[keyHash] 192 - if !ok { 193 - return fmt.Errorf("key not found") 194 - } 195 - 196 - apiKey.LastUsed = time.Now() 197 - return s.save() 198 - } 199 - 200 - // load reads keys from disk 201 - func (s *Store) load() error { 202 - data, err := os.ReadFile(s.filePath) 203 - if err != nil { 204 - return err 205 - } 206 - 207 - var pd persistentData 208 - if err := json.Unmarshal(data, &pd); err != nil { 209 - return fmt.Errorf("failed to unmarshal keys: %w", err) 210 - } 211 - 212 - // Rebuild in-memory structures 213 - for _, apiKey := range pd.Keys { 214 - s.keys[apiKey.KeyHash] = apiKey 215 - s.byDID[apiKey.DID] = append(s.byDID[apiKey.DID], apiKey.KeyHash) 216 - } 217 - 218 - return nil 219 - } 220 - 221 - // save writes keys to disk 222 - func (s *Store) save() error { 223 - // Collect all keys 224 - allKeys := make([]*APIKey, 0, len(s.keys)) 225 - for _, apiKey := range s.keys { 226 - allKeys = append(allKeys, apiKey) 227 - } 228 - 229 - pd := persistentData{ 230 - Keys: allKeys, 231 - } 232 - 233 - data, err := json.MarshalIndent(pd, "", " ") 234 - if err != nil { 235 - return fmt.Errorf("failed to marshal keys: %w", err) 236 - } 237 - 238 - // Write atomically with temp file + rename 239 - tmpPath := s.filePath + ".tmp" 240 - if err := os.WriteFile(tmpPath, data, 0600); err != nil { 241 - return fmt.Errorf("failed to write temp file: %w", err) 242 - } 243 - 244 - if err := os.Rename(tmpPath, s.filePath); err != nil { 245 - return fmt.Errorf("failed to rename temp file: %w", err) 246 - } 247 - 248 - return nil 249 - }
+14
pkg/appview/appview.go
··· 63 63 } 64 64 return digest[:length] + "..." 65 65 }, 66 + 67 + "firstChar": func(s string) string { 68 + if len(s) == 0 { 69 + return "?" 70 + } 71 + return string([]rune(s)[0]) 72 + }, 73 + 74 + "trimPrefix": func(s, prefix string) string { 75 + if len(s) >= len(prefix) && s[:len(prefix)] == prefix { 76 + return s[len(prefix):] 77 + } 78 + return s 79 + }, 66 80 } 67 81 68 82 tmpl := template.New("").Funcs(funcMap)
+1
pkg/appview/db/models.go
··· 7 7 DID string 8 8 Handle string 9 9 PDSEndpoint string 10 + Avatar string 10 11 LastSeen time.Time 11 12 } 12 13
+6 -5
pkg/appview/db/queries.go
··· 168 168 func GetUserByDID(db *sql.DB, did string) (*User, error) { 169 169 var user User 170 170 err := db.QueryRow(` 171 - SELECT did, handle, pds_endpoint, last_seen 171 + SELECT did, handle, pds_endpoint, avatar, last_seen 172 172 FROM users 173 173 WHERE did = ? 174 - `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.LastSeen) 174 + `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.Avatar, &user.LastSeen) 175 175 176 176 if err == sql.ErrNoRows { 177 177 return nil, nil ··· 186 186 // UpsertUser inserts or updates a user record 187 187 func UpsertUser(db *sql.DB, user *User) error { 188 188 _, err := db.Exec(` 189 - INSERT INTO users (did, handle, pds_endpoint, last_seen) 190 - VALUES (?, ?, ?, ?) 189 + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) 190 + VALUES (?, ?, ?, ?, ?) 191 191 ON CONFLICT(did) DO UPDATE SET 192 192 handle = excluded.handle, 193 193 pds_endpoint = excluded.pds_endpoint, 194 + avatar = excluded.avatar, 194 195 last_seen = excluded.last_seen 195 - `, user.DID, user.Handle, user.PDSEndpoint, user.LastSeen) 196 + `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen) 196 197 return err 197 198 } 198 199
+79
pkg/appview/db/schema.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 did TEXT PRIMARY KEY, 12 13 handle TEXT NOT NULL, 13 14 pds_endpoint TEXT NOT NULL, 15 + avatar TEXT, 14 16 last_seen TIMESTAMP NOT NULL, 15 17 UNIQUE(handle) 16 18 ); ··· 90 92 return nil, err 91 93 } 92 94 95 + // Migration: Add avatar column if it doesn't exist 96 + _, err = db.Exec(`ALTER TABLE users ADD COLUMN avatar TEXT`) 97 + // Ignore error if column already exists 98 + if err != nil && !strings.Contains(err.Error(), "duplicate column") { 99 + // Log but don't fail - column might already exist 100 + } 101 + 102 + // Migration: Convert old cdn.bsky.app avatar URLs to imgs.blue 103 + if err := migrateCDNURLs(db); err != nil { 104 + // Log but don't fail - not critical 105 + println("Warning: Failed to migrate CDN URLs:", err.Error()) 106 + } 107 + 93 108 return db, nil 94 109 } 110 + 111 + // migrateCDNURLs converts old cdn.bsky.app avatar URLs to imgs.blue format 112 + // Old format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg 113 + // New format: https://imgs.blue/did:plc:abc123/bafkreibxuy73... 114 + func migrateCDNURLs(db *sql.DB) error { 115 + // Find all users with cdn.bsky.app avatars 116 + rows, err := db.Query(`SELECT did, avatar FROM users WHERE avatar LIKE 'https://cdn.bsky.app/%'`) 117 + if err != nil { 118 + return err 119 + } 120 + defer rows.Close() 121 + 122 + updates := []struct { 123 + did string 124 + newURL string 125 + }{} 126 + 127 + for rows.Next() { 128 + var did, oldURL string 129 + if err := rows.Scan(&did, &oldURL); err != nil { 130 + continue 131 + } 132 + 133 + // Extract CID from old URL 134 + // Format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg 135 + parts := strings.Split(oldURL, "/") 136 + if len(parts) < 7 { 137 + continue 138 + } 139 + 140 + // Get the last part which contains CID@format 141 + cidPart := parts[len(parts)-1] 142 + // Strip off @jpeg or @png suffix 143 + cid := strings.Split(cidPart, "@")[0] 144 + 145 + // Construct new imgs.blue URL 146 + newURL := "https://imgs.blue/" + did + "/" + cid 147 + 148 + updates = append(updates, struct { 149 + did string 150 + newURL string 151 + }{did, newURL}) 152 + } 153 + 154 + // Update all users 155 + stmt, err := db.Prepare(`UPDATE users SET avatar = ? WHERE did = ?`) 156 + if err != nil { 157 + return err 158 + } 159 + defer stmt.Close() 160 + 161 + for _, update := range updates { 162 + if _, err := stmt.Exec(update.newURL, update.did); err != nil { 163 + // Log but continue 164 + println("Warning: Failed to update avatar for", update.did, ":", err.Error()) 165 + } 166 + } 167 + 168 + if len(updates) > 0 { 169 + println("Migrated", len(updates), "avatar URLs from cdn.bsky.app to imgs.blue") 170 + } 171 + 172 + return nil 173 + }
+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 + }
-91
pkg/appview/handlers/apikeys.go
··· 1 - package handlers 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - 8 - "atcr.io/pkg/appview/apikey" 9 - "atcr.io/pkg/appview/middleware" 10 - "github.com/gorilla/mux" 11 - ) 12 - 13 - // GenerateAPIKeyHandler handles POST /api/keys 14 - type GenerateAPIKeyHandler struct { 15 - Store *apikey.Store 16 - } 17 - 18 - func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 19 - user := middleware.GetUser(r) 20 - if user == nil { 21 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 22 - return 23 - } 24 - 25 - name := r.FormValue("name") 26 - if name == "" { 27 - name = "Unnamed Key" 28 - } 29 - 30 - key, keyID, err := h.Store.Generate(user.DID, user.Handle, name) 31 - if err != nil { 32 - fmt.Printf("ERROR [apikeys]: Failed to generate key for DID=%s: %v\n", user.DID, err) 33 - http.Error(w, "Failed to generate key", http.StatusInternalServerError) 34 - return 35 - } 36 - 37 - fmt.Printf("INFO [apikeys]: Generated API key for DID=%s, handle=%s, name=%s, keyID=%s\n", 38 - user.DID, user.Handle, name, keyID) 39 - 40 - // Return key (shown once!) 41 - w.Header().Set("Content-Type", "application/json") 42 - json.NewEncoder(w).Encode(map[string]string{ 43 - "id": keyID, 44 - "key": key, 45 - }) 46 - } 47 - 48 - // ListAPIKeysHandler handles GET /api/keys 49 - type ListAPIKeysHandler struct { 50 - Store *apikey.Store 51 - } 52 - 53 - func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 54 - user := middleware.GetUser(r) 55 - if user == nil { 56 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 57 - return 58 - } 59 - 60 - keys := h.Store.List(user.DID) 61 - 62 - w.Header().Set("Content-Type", "application/json") 63 - json.NewEncoder(w).Encode(keys) 64 - } 65 - 66 - // DeleteAPIKeyHandler handles DELETE /api/keys/{id} 67 - type DeleteAPIKeyHandler struct { 68 - Store *apikey.Store 69 - } 70 - 71 - func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 72 - user := middleware.GetUser(r) 73 - if user == nil { 74 - http.Error(w, "Unauthorized", http.StatusUnauthorized) 75 - return 76 - } 77 - 78 - vars := mux.Vars(r) 79 - keyID := vars["id"] 80 - 81 - if err := h.Store.Delete(user.DID, keyID); err != nil { 82 - fmt.Printf("ERROR [apikeys]: Failed to delete key for DID=%s, keyID=%s: %v\n", 83 - user.DID, keyID, err) 84 - http.Error(w, "Failed to delete key", http.StatusInternalServerError) 85 - return 86 - } 87 - 88 - fmt.Printf("INFO [apikeys]: Deleted API key for DID=%s, keyID=%s\n", user.DID, keyID) 89 - 90 - w.WriteHeader(http.StatusNoContent) 91 - }
+75 -1
pkg/appview/handlers/auth.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "fmt" 4 5 "html/template" 5 6 "net/http" 7 + "time" 8 + 9 + "atcr.io/pkg/auth/oauth" 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 6 12 ) 7 13 8 14 // LoginHandler shows the OAuth login form ··· 31 37 } 32 38 33 39 // LoginSubmitHandler processes the login form submission 34 - type LoginSubmitHandler struct{} 40 + type LoginSubmitHandler struct { 41 + Refresher *oauth.Refresher 42 + Directory identity.Directory 43 + SessionStore UISessionStore 44 + } 45 + 46 + // UISessionStore is the interface for UI session management 47 + type UISessionStore interface { 48 + CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) 49 + } 35 50 36 51 func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 37 52 if r.Method != http.MethodPost { ··· 41 56 42 57 handle := r.FormValue("handle") 43 58 returnTo := r.FormValue("return_to") 59 + if returnTo == "" { 60 + returnTo = "/" 61 + } 44 62 45 63 if handle == "" { 46 64 http.Redirect(w, r, "/auth/oauth/login?return_to="+returnTo+"&error=handle_required", http.StatusFound) 47 65 return 48 66 } 67 + 68 + // Attempt silent login first 69 + if h.Refresher != nil && h.Directory != nil && h.SessionStore != nil { 70 + // Parse handle 71 + handleSyntax, err := syntax.ParseHandle(handle) 72 + if err == nil { 73 + // Resolve handle to identity (DID + PDS endpoint) 74 + ident, err := h.Directory.LookupHandle(r.Context(), handleSyntax) 75 + if err == nil { 76 + did := ident.DID.String() 77 + 78 + // Try to get existing OAuth session 79 + _, err := h.Refresher.GetSession(r.Context(), did) 80 + if err == nil { 81 + // Found valid OAuth session! Create UI session silently 82 + fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did) 83 + 84 + // Get PDS endpoint from identity 85 + pdsEndpoint := ident.PDSEndpoint() 86 + 87 + // Get OAuth sessionID from refresher 88 + sessionID := h.Refresher.GetSessionID(did) 89 + 90 + uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour) 91 + if err == nil { 92 + // Set session cookie 93 + http.SetCookie(w, &http.Cookie{ 94 + Name: "atcr_session", 95 + Value: uiSessionID, 96 + Path: "/", 97 + MaxAge: 30 * 86400, // 30 days 98 + HttpOnly: true, 99 + Secure: true, 100 + SameSite: http.SameSiteLaxMode, 101 + }) 102 + 103 + // Redirect to return URL 104 + fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo) 105 + http.Redirect(w, r, returnTo, http.StatusFound) 106 + return 107 + } 108 + 109 + fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) 110 + } else { 111 + fmt.Printf("DEBUG [auth]: No valid OAuth session found for %s: %v\n", handle, err) 112 + } 113 + } else { 114 + fmt.Printf("DEBUG [auth]: Failed to resolve handle %s: %v\n", handle, err) 115 + } 116 + } else { 117 + fmt.Printf("DEBUG [auth]: Failed to parse handle %s: %v\n", handle, err) 118 + } 119 + } 120 + 121 + // Silent login failed or not configured - proceed with full OAuth flow 122 + fmt.Printf("DEBUG [auth]: Proceeding with full OAuth flow for %s\n", handle) 49 123 50 124 // Store return_to in cookie so callback can use it 51 125 http.SetCookie(w, &http.Cookie{
+531
pkg/appview/handlers/device.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "html/template" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/gorilla/mux" 11 + 12 + "atcr.io/pkg/appview/device" 13 + "atcr.io/pkg/appview/session" 14 + ) 15 + 16 + // DeviceCodeRequest is the request to start device authorization 17 + type DeviceCodeRequest struct { 18 + DeviceName string `json:"device_name"` 19 + } 20 + 21 + // DeviceCodeResponse is the response with user and device codes 22 + type DeviceCodeResponse struct { 23 + DeviceCode string `json:"device_code"` 24 + UserCode string `json:"user_code"` 25 + VerificationURI string `json:"verification_uri"` 26 + ExpiresIn int `json:"expires_in"` 27 + Interval int `json:"interval"` 28 + } 29 + 30 + // DeviceCodeHandler handles POST /auth/device/code 31 + type DeviceCodeHandler struct { 32 + Store *device.Store 33 + AppViewBaseURL string // e.g., "http://localhost:5000" 34 + } 35 + 36 + func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 37 + if r.Method != http.MethodPost { 38 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 39 + return 40 + } 41 + 42 + var req DeviceCodeRequest 43 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 44 + http.Error(w, "invalid request", http.StatusBadRequest) 45 + return 46 + } 47 + 48 + // Default device name if not provided 49 + if req.DeviceName == "" { 50 + req.DeviceName = "Unknown Device" 51 + } 52 + 53 + // Get client IP 54 + ip := getClientIP(r) 55 + 56 + // Get user agent 57 + userAgent := r.UserAgent() 58 + 59 + // Create pending authorization 60 + pending, err := h.Store.CreatePendingAuth(req.DeviceName, ip, userAgent) 61 + if err != nil { 62 + http.Error(w, "failed to create authorization", http.StatusInternalServerError) 63 + return 64 + } 65 + 66 + // Return device code info 67 + resp := DeviceCodeResponse{ 68 + DeviceCode: pending.DeviceCode, 69 + UserCode: pending.UserCode, 70 + VerificationURI: h.AppViewBaseURL + "/device", 71 + ExpiresIn: 600, // 10 minutes 72 + Interval: 5, // Poll every 5 seconds 73 + } 74 + 75 + w.Header().Set("Content-Type", "application/json") 76 + json.NewEncoder(w).Encode(resp) 77 + } 78 + 79 + // DeviceTokenRequest is the request to poll for device authorization 80 + type DeviceTokenRequest struct { 81 + DeviceCode string `json:"device_code"` 82 + } 83 + 84 + // DeviceTokenResponse is the response with device secret or error 85 + type DeviceTokenResponse struct { 86 + DeviceSecret string `json:"device_secret,omitempty"` 87 + Handle string `json:"handle,omitempty"` 88 + DID string `json:"did,omitempty"` 89 + Error string `json:"error,omitempty"` 90 + } 91 + 92 + // DeviceTokenHandler handles POST /auth/device/token 93 + type DeviceTokenHandler struct { 94 + Store *device.Store 95 + } 96 + 97 + func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 98 + if r.Method != http.MethodPost { 99 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 100 + return 101 + } 102 + 103 + var req DeviceTokenRequest 104 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 105 + http.Error(w, "invalid request", http.StatusBadRequest) 106 + return 107 + } 108 + 109 + // Get pending authorization 110 + pending, ok := h.Store.GetPendingByDeviceCode(req.DeviceCode) 111 + if !ok { 112 + resp := DeviceTokenResponse{ 113 + Error: "expired_token", 114 + } 115 + w.Header().Set("Content-Type", "application/json") 116 + json.NewEncoder(w).Encode(resp) 117 + return 118 + } 119 + 120 + // Check if approved 121 + if pending.ApprovedDID == "" { 122 + // Still pending 123 + resp := DeviceTokenResponse{ 124 + Error: "authorization_pending", 125 + } 126 + w.Header().Set("Content-Type", "application/json") 127 + json.NewEncoder(w).Encode(resp) 128 + return 129 + } 130 + 131 + // Approved! Get device from store to find handle 132 + devices := h.Store.ListDevices(pending.ApprovedDID) 133 + var handle string 134 + for _, d := range devices { 135 + if d.DID == pending.ApprovedDID { 136 + handle = d.Handle 137 + break 138 + } 139 + } 140 + 141 + // Return device secret 142 + resp := DeviceTokenResponse{ 143 + DeviceSecret: pending.DeviceSecret, 144 + Handle: handle, 145 + DID: pending.ApprovedDID, 146 + } 147 + 148 + w.Header().Set("Content-Type", "application/json") 149 + json.NewEncoder(w).Encode(resp) 150 + } 151 + 152 + // DeviceApprovalPageHandler handles GET /device 153 + type DeviceApprovalPageHandler struct { 154 + Store *device.Store 155 + SessionStore *session.Store 156 + } 157 + 158 + func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 159 + if r.Method != http.MethodGet { 160 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 161 + return 162 + } 163 + 164 + // Check if user is logged in 165 + sessionID, ok := session.GetSessionID(r) 166 + if !ok { 167 + // Not logged in - redirect to login with return URL 168 + http.SetCookie(w, &http.Cookie{ 169 + Name: "oauth_return_to", 170 + Value: r.URL.RequestURI(), 171 + Path: "/", 172 + MaxAge: 600, // 10 minutes 173 + HttpOnly: true, 174 + }) 175 + http.Redirect(w, r, "/login", http.StatusFound) 176 + return 177 + } 178 + 179 + sess, ok := h.SessionStore.Get(sessionID) 180 + if !ok { 181 + // Invalid session 182 + http.SetCookie(w, &http.Cookie{ 183 + Name: "oauth_return_to", 184 + Value: r.URL.RequestURI(), 185 + Path: "/", 186 + MaxAge: 600, 187 + HttpOnly: true, 188 + }) 189 + http.Redirect(w, r, "/login", http.StatusFound) 190 + return 191 + } 192 + 193 + // Get user code from query 194 + userCode := r.URL.Query().Get("user_code") 195 + if userCode == "" { 196 + http.Error(w, "user_code required", http.StatusBadRequest) 197 + return 198 + } 199 + 200 + // Get pending authorization 201 + pending, ok := h.Store.GetPendingByUserCode(userCode) 202 + if !ok { 203 + h.renderError(w, "Invalid or expired authorization code") 204 + return 205 + } 206 + 207 + // Check if already approved 208 + if pending.ApprovedDID != "" { 209 + h.renderSuccess(w, pending.DeviceName) 210 + return 211 + } 212 + 213 + // Render approval page 214 + h.renderApprovalPage(w, sess.Handle, pending) 215 + } 216 + 217 + // DeviceApproveRequest is the request to approve a device 218 + type DeviceApproveRequest struct { 219 + UserCode string `json:"user_code"` 220 + Approve bool `json:"approve"` 221 + } 222 + 223 + // DeviceApproveHandler handles POST /device/approve 224 + type DeviceApproveHandler struct { 225 + Store *device.Store 226 + SessionStore *session.Store 227 + } 228 + 229 + func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 230 + if r.Method != http.MethodPost { 231 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 232 + return 233 + } 234 + 235 + // Check session 236 + sessionID, ok := session.GetSessionID(r) 237 + if !ok { 238 + http.Error(w, "unauthorized", http.StatusUnauthorized) 239 + return 240 + } 241 + 242 + sess, ok := h.SessionStore.Get(sessionID) 243 + if !ok { 244 + http.Error(w, "unauthorized", http.StatusUnauthorized) 245 + return 246 + } 247 + 248 + var req DeviceApproveRequest 249 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 250 + http.Error(w, "invalid request", http.StatusBadRequest) 251 + return 252 + } 253 + 254 + if !req.Approve { 255 + // User denied 256 + w.Header().Set("Content-Type", "application/json") 257 + json.NewEncoder(w).Encode(map[string]string{"status": "denied"}) 258 + return 259 + } 260 + 261 + // Approve the device 262 + _, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle) 263 + if err != nil { 264 + http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError) 265 + return 266 + } 267 + 268 + w.Header().Set("Content-Type", "application/json") 269 + json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) 270 + } 271 + 272 + // ListDevicesHandler handles GET /api/devices 273 + type ListDevicesHandler struct { 274 + Store *device.Store 275 + SessionStore *session.Store 276 + } 277 + 278 + func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 279 + if r.Method != http.MethodGet { 280 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 281 + return 282 + } 283 + 284 + // Check session 285 + sessionID, ok := session.GetSessionID(r) 286 + if !ok { 287 + http.Error(w, "unauthorized", http.StatusUnauthorized) 288 + return 289 + } 290 + 291 + sess, ok := h.SessionStore.Get(sessionID) 292 + if !ok { 293 + http.Error(w, "unauthorized", http.StatusUnauthorized) 294 + return 295 + } 296 + 297 + // Get devices for this user 298 + devices := h.Store.ListDevices(sess.DID) 299 + 300 + w.Header().Set("Content-Type", "application/json") 301 + json.NewEncoder(w).Encode(devices) 302 + } 303 + 304 + // RevokeDeviceHandler handles DELETE /api/devices/{id} 305 + type RevokeDeviceHandler struct { 306 + Store *device.Store 307 + SessionStore *session.Store 308 + } 309 + 310 + func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 311 + if r.Method != http.MethodDelete { 312 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 313 + return 314 + } 315 + 316 + // Check session 317 + sessionID, ok := session.GetSessionID(r) 318 + if !ok { 319 + http.Error(w, "unauthorized", http.StatusUnauthorized) 320 + return 321 + } 322 + 323 + sess, ok := h.SessionStore.Get(sessionID) 324 + if !ok { 325 + http.Error(w, "unauthorized", http.StatusUnauthorized) 326 + return 327 + } 328 + 329 + // Get device ID from URL 330 + vars := mux.Vars(r) 331 + deviceID := vars["id"] 332 + if deviceID == "" { 333 + http.Error(w, "device ID required", http.StatusBadRequest) 334 + return 335 + } 336 + 337 + // Revoke device 338 + if err := h.Store.RevokeDevice(sess.DID, deviceID); err != nil { 339 + http.Error(w, fmt.Sprintf("failed to revoke: %v", err), http.StatusInternalServerError) 340 + return 341 + } 342 + 343 + w.WriteHeader(http.StatusNoContent) 344 + } 345 + 346 + // Helper functions 347 + 348 + func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *device.PendingAuthorization) { 349 + tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate)) 350 + data := struct { 351 + Handle string 352 + DeviceName string 353 + UserCode string 354 + IPAddress string 355 + }{ 356 + Handle: handle, 357 + DeviceName: pending.DeviceName, 358 + UserCode: pending.UserCode, 359 + IPAddress: pending.IPAddress, 360 + } 361 + 362 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 363 + tmpl.Execute(w, data) 364 + } 365 + 366 + func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, deviceName string) { 367 + tmpl := template.Must(template.New("success").Parse(deviceSuccessTemplate)) 368 + data := struct { 369 + DeviceName string 370 + }{ 371 + DeviceName: deviceName, 372 + } 373 + 374 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 375 + tmpl.Execute(w, data) 376 + } 377 + 378 + func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, message string) { 379 + tmpl := template.Must(template.New("error").Parse(deviceErrorTemplate)) 380 + data := struct { 381 + Message string 382 + }{ 383 + Message: message, 384 + } 385 + 386 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 387 + w.WriteHeader(http.StatusBadRequest) 388 + tmpl.Execute(w, data) 389 + } 390 + 391 + func getClientIP(r *http.Request) string { 392 + // Check X-Forwarded-For header 393 + xff := r.Header.Get("X-Forwarded-For") 394 + if xff != "" { 395 + parts := strings.Split(xff, ",") 396 + return strings.TrimSpace(parts[0]) 397 + } 398 + 399 + // Check X-Real-IP header 400 + xri := r.Header.Get("X-Real-IP") 401 + if xri != "" { 402 + return xri 403 + } 404 + 405 + // Fall back to RemoteAddr 406 + parts := strings.Split(r.RemoteAddr, ":") 407 + if len(parts) > 0 { 408 + return parts[0] 409 + } 410 + 411 + return r.RemoteAddr 412 + } 413 + 414 + // HTML templates 415 + 416 + const deviceApprovalTemplate = ` 417 + <!DOCTYPE html> 418 + <html> 419 + <head> 420 + <title>Authorize Device - ATCR</title> 421 + <style> 422 + body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } 423 + .approval-box { background: #e3f2fd; border: 1px solid #90caf9; padding: 30px; border-radius: 8px; } 424 + .user-code { font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 20px 0; color: #1976d2; } 425 + .device-info { background: #fff; padding: 15px; border-radius: 4px; margin: 15px 0; } 426 + .device-info dt { font-weight: bold; margin-top: 10px; } 427 + .device-info dd { margin-left: 0; color: #666; } 428 + .actions { text-align: center; margin-top: 30px; } 429 + button { font-size: 16px; padding: 12px 30px; margin: 0 10px; border: none; border-radius: 4px; cursor: pointer; } 430 + .approve { background: #4caf50; color: white; } 431 + .approve:hover { background: #45a049; } 432 + .deny { background: #f44336; color: white; } 433 + .deny:hover { background: #da190b; } 434 + </style> 435 + </head> 436 + <body> 437 + <div class="approval-box"> 438 + <h1>Authorize Device</h1> 439 + <p>User: <strong>{{.Handle}}</strong></p> 440 + 441 + <div class="user-code">{{.UserCode}}</div> 442 + 443 + <div class="device-info"> 444 + <dl> 445 + <dt>Device Name:</dt> 446 + <dd>{{.DeviceName}}</dd> 447 + <dt>IP Address:</dt> 448 + <dd>{{.IPAddress}}</dd> 449 + </dl> 450 + </div> 451 + 452 + <p><strong>Do you want to authorize this device?</strong></p> 453 + <p>This device will be able to push and pull container images to your registry.</p> 454 + 455 + <div class="actions"> 456 + <button class="approve" onclick="approve(true)">Approve</button> 457 + <button class="deny" onclick="approve(false)">Deny</button> 458 + </div> 459 + </div> 460 + 461 + <script> 462 + async function approve(approved) { 463 + const resp = await fetch('/device/approve', { 464 + method: 'POST', 465 + headers: {'Content-Type': 'application/json'}, 466 + body: JSON.stringify({ 467 + user_code: '{{.UserCode}}', 468 + approve: approved 469 + }) 470 + }); 471 + 472 + if (resp.ok) { 473 + if (approved) { 474 + window.location.href = '/device?user_code={{.UserCode}}'; 475 + } else { 476 + alert('Device authorization denied'); 477 + window.location.href = '/'; 478 + } 479 + } else { 480 + alert('Failed to process authorization'); 481 + } 482 + } 483 + </script> 484 + </body> 485 + </html> 486 + ` 487 + 488 + const deviceSuccessTemplate = ` 489 + <!DOCTYPE html> 490 + <html> 491 + <head> 492 + <title>Device Authorized - ATCR</title> 493 + <style> 494 + body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } 495 + .success { background: #d4edda; border: 1px solid #c3e6cb; padding: 30px; border-radius: 8px; } 496 + h1 { color: #155724; } 497 + a { color: #007bff; } 498 + </style> 499 + </head> 500 + <body> 501 + <div class="success"> 502 + <h1>✓ Device Authorized!</h1> 503 + <p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p> 504 + <p>You can now close this window and return to your terminal.</p> 505 + <p><a href="/settings">View your authorized devices</a></p> 506 + </div> 507 + </body> 508 + </html> 509 + ` 510 + 511 + const deviceErrorTemplate = ` 512 + <!DOCTYPE html> 513 + <html> 514 + <head> 515 + <title>Authorization Error - ATCR</title> 516 + <style> 517 + body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } 518 + .error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 30px; border-radius: 8px; } 519 + h1 { color: #721c24; } 520 + a { color: #007bff; } 521 + </style> 522 + </head> 523 + <body> 524 + <div class="error"> 525 + <h1>✗ Authorization Error</h1> 526 + <p>{{.Message}}</p> 527 + <p><a href="/">Return to home</a></p> 528 + </div> 529 + </body> 530 + </html> 531 + `
+5 -2
pkg/appview/handlers/settings.go
··· 14 14 15 15 // SettingsHandler handles the settings page 16 16 type SettingsHandler struct { 17 - Templates *template.Template 18 - Refresher *oauth.Refresher 17 + Templates *template.Template 18 + Refresher *oauth.Refresher 19 + RegistryURL string 19 20 } 20 21 21 22 func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ··· 60 61 } 61 62 SessionExpiry time.Time 62 63 Query string 64 + RegistryURL string 63 65 }{ 64 66 User: user, 65 67 SessionExpiry: time.Now().Add(24 * time.Hour), // TODO: Get from actual session 66 68 Query: r.URL.Query().Get("q"), 69 + RegistryURL: h.RegistryURL, 67 70 } 68 71 69 72 data.Profile.Handle = user.Handle
+13
pkg/appview/jetstream/backfill.go
··· 363 363 pdsEndpoint = "https://bsky.social" 364 364 } 365 365 366 + // Fetch user's Bluesky profile (including avatar) 367 + // Use public Bluesky AppView API (doesn't require auth for public profiles) 368 + avatar := "" 369 + publicClient := atproto.NewClient("https://public.api.bsky.app", "", "") 370 + profile, err := publicClient.GetActorProfile(ctx, resolvedDID) 371 + if err != nil { 372 + fmt.Printf("WARNING [backfill]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err) 373 + // Continue without avatar 374 + } else { 375 + avatar = profile.Avatar 376 + } 377 + 366 378 // Upsert to database 367 379 user := &db.User{ 368 380 DID: resolvedDID, 369 381 Handle: handle, 370 382 PDSEndpoint: pdsEndpoint, 383 + Avatar: avatar, 371 384 LastSeen: time.Now(), 372 385 } 373 386
+27
pkg/appview/jetstream/worker.go
··· 36 36 userCache *UserCache 37 37 directory identity.Directory 38 38 eventCallback EventCallback 39 + connStartTime time.Time // Track when connection started for debugging 39 40 } 40 41 41 42 // NewWorker creates a new Jetstream worker ··· 93 94 } 94 95 defer conn.Close() 95 96 97 + // Track connection start time for debugging 98 + w.connStartTime = time.Now() 99 + 96 100 // Create zstd decoder for decompressing messages 97 101 decoder, err := zstd.NewReader(nil) 98 102 if err != nil { ··· 122 126 default: 123 127 _, message, err := conn.ReadMessage() 124 128 if err != nil { 129 + // Calculate connection duration and idle time for debugging 130 + connDuration := time.Since(w.connStartTime) 131 + timeSinceLastEvent := time.Since(lastHeartbeat) 132 + 133 + // Log detailed context about the failure 134 + fmt.Printf("Jetstream: Connection closed after %s\n", connDuration) 135 + fmt.Printf(" - Events in last 30s: %d\n", eventCount) 136 + fmt.Printf(" - Time since last event: %s\n", timeSinceLastEvent) 137 + fmt.Printf(" - Error: %v\n", err) 138 + 125 139 return fmt.Errorf("failed to read message: %w", err) 126 140 } 127 141 ··· 241 255 pdsEndpoint = "https://bsky.social" 242 256 } 243 257 258 + // Fetch user's Bluesky profile (including avatar) 259 + // Use public Bluesky AppView API (doesn't require auth for public profiles) 260 + avatar := "" 261 + publicClient := atproto.NewClient("https://public.api.bsky.app", "", "") 262 + profile, err := publicClient.GetActorProfile(ctx, resolvedDID) 263 + if err != nil { 264 + fmt.Printf("WARNING [worker]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err) 265 + // Continue without avatar 266 + } else { 267 + avatar = profile.Avatar 268 + } 269 + 244 270 // Cache the user 245 271 user := &db.User{ 246 272 DID: resolvedDID, 247 273 Handle: handle, 248 274 PDSEndpoint: pdsEndpoint, 275 + Avatar: avatar, 249 276 LastSeen: time.Now(), 250 277 } 251 278 w.userCache.cache[did] = user
+21 -10
pkg/appview/middleware/auth.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "net/http" 6 7 7 8 "atcr.io/pkg/appview/db" ··· 13 14 const userKey contextKey = "user" 14 15 15 16 // RequireAuth is middleware that requires authentication 16 - func RequireAuth(store *session.Store) func(http.Handler) http.Handler { 17 + func RequireAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler { 17 18 return func(next http.Handler) http.Handler { 18 19 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 20 sessionID, ok := session.GetSessionID(r) ··· 28 29 return 29 30 } 30 31 31 - user := &db.User{ 32 - DID: sess.DID, 33 - Handle: sess.Handle, 34 - PDSEndpoint: sess.PDSEndpoint, 32 + // Look up full user from database to get avatar 33 + user, err := db.GetUserByDID(database, sess.DID) 34 + if err != nil || user == nil { 35 + // Fallback to session data if DB lookup fails 36 + user = &db.User{ 37 + DID: sess.DID, 38 + Handle: sess.Handle, 39 + PDSEndpoint: sess.PDSEndpoint, 40 + } 35 41 } 36 42 37 43 ctx := context.WithValue(r.Context(), userKey, user) ··· 41 47 } 42 48 43 49 // OptionalAuth is middleware that optionally includes user if authenticated 44 - func OptionalAuth(store *session.Store) func(http.Handler) http.Handler { 50 + func OptionalAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler { 45 51 return func(next http.Handler) http.Handler { 46 52 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 53 sessionID, ok := session.GetSessionID(r) 48 54 if ok { 49 55 if sess, ok := store.Get(sessionID); ok { 50 - user := &db.User{ 51 - DID: sess.DID, 52 - Handle: sess.Handle, 53 - PDSEndpoint: sess.PDSEndpoint, 56 + // Look up full user from database to get avatar 57 + user, err := db.GetUserByDID(database, sess.DID) 58 + if err != nil || user == nil { 59 + // Fallback to session data if DB lookup fails 60 + user = &db.User{ 61 + DID: sess.DID, 62 + Handle: sess.Handle, 63 + PDSEndpoint: sess.PDSEndpoint, 64 + } 54 65 } 55 66 ctx := context.WithValue(r.Context(), userKey, user) 56 67 r = r.WithContext(ctx)
+17 -10
pkg/appview/session/session.go
··· 13 13 14 14 // Session represents a user session 15 15 type Session struct { 16 - ID string 17 - DID string 18 - Handle string 19 - PDSEndpoint string 20 - ExpiresAt time.Time 16 + ID string 17 + DID string 18 + Handle string 19 + PDSEndpoint string 20 + OAuthSessionID string // Store OAuth sessionID for resuming 21 + ExpiresAt time.Time 21 22 } 22 23 23 24 // Store manages user sessions ··· 89 90 90 91 // Create creates a new session and returns the session ID 91 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) { 92 98 s.mu.Lock() 93 99 defer s.mu.Unlock() 94 100 ··· 99 105 } 100 106 101 107 sess := &Session{ 102 - ID: base64.URLEncoding.EncodeToString(b), 103 - DID: did, 104 - Handle: handle, 105 - PDSEndpoint: pdsEndpoint, 106 - ExpiresAt: time.Now().Add(duration), 108 + ID: base64.URLEncoding.EncodeToString(b), 109 + DID: did, 110 + Handle: handle, 111 + PDSEndpoint: pdsEndpoint, 112 + OAuthSessionID: oauthSessionID, 113 + ExpiresAt: time.Now().Add(duration), 107 114 } 108 115 109 116 s.sessions[sess.ID] = sess
+109 -12
pkg/appview/static/css/style.css
··· 32 32 /* Navigation */ 33 33 .navbar { 34 34 background: var(--fg); 35 - color: white; 35 + color:var(--bg); 36 36 padding: 1rem 2rem; 37 37 display: flex; 38 38 justify-content: space-between; ··· 41 41 } 42 42 43 43 .nav-brand a { 44 - color: white; 44 + color:var(--bg); 45 45 text-decoration: none; 46 46 font-size: 1.5rem; 47 47 font-weight: bold; ··· 68 68 } 69 69 70 70 .nav-links a { 71 - color: white; 71 + color:var(--fg); 72 72 text-decoration: none; 73 73 padding: 0.5rem 1rem; 74 74 } 75 75 76 76 .nav-links a:hover { 77 - background: rgba(255, 255, 255, 0.1); 77 + background:var(--secondary); 78 + border-radius: 4px; 79 + } 80 + 81 + /* User dropdown */ 82 + .user-dropdown { 83 + position: relative; 84 + } 85 + 86 + .user-menu-btn { 87 + display: flex; 88 + align-items: center; 89 + gap: 0.5rem; 90 + background: transparent; 91 + color:var(--bg); 92 + border: none; 93 + padding: 0.5rem; 94 + cursor: pointer; 78 95 border-radius: 4px; 96 + transition: background 0.2s; 97 + } 98 + 99 + .user-menu-btn:hover { 100 + background:var(--secondary); 101 + } 102 + 103 + .user-avatar { 104 + width: 32px; 105 + height: 32px; 106 + border-radius: 50%; 107 + object-fit: cover; 108 + } 109 + 110 + .user-avatar-placeholder { 111 + width: 32px; 112 + height: 32px; 113 + border-radius: 50%; 114 + background: var(--primary); 115 + display: flex; 116 + align-items: center; 117 + justify-content: center; 118 + font-weight: bold; 119 + text-transform: uppercase; 79 120 } 80 121 81 122 .user-handle { 82 - color: #aaa; 123 + color: white; 124 + font-size: 0.95rem; 125 + } 126 + 127 + .dropdown-arrow { 128 + transition: transform 0.2s; 129 + } 130 + 131 + .user-menu-btn[aria-expanded="true"] .dropdown-arrow { 132 + transform: rotate(180deg); 133 + } 134 + 135 + .dropdown-menu { 136 + position: absolute; 137 + top: calc(100% + 0.5rem); 138 + right: 0; 139 + background:var(--bg); 140 + border: 1px solid var(--border); 141 + border-radius: 8px; 142 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 143 + min-width: 200px; 144 + overflow: hidden; 145 + z-index: 1000; 83 146 } 84 147 85 - .settings-icon { 86 - font-size: 1.2rem; 148 + .dropdown-menu[hidden] { 149 + display: none; 150 + } 151 + 152 + .dropdown-item { 153 + display: block; 154 + width: 100%; 155 + padding: 0.75rem 1rem; 156 + text-align: left; 157 + color: var(--fg); 158 + text-decoration: none; 159 + border: none; 160 + background:var(--bg); 161 + cursor: pointer; 162 + transition: background 0.2s; 163 + font-size: 0.95rem; 164 + } 165 + 166 + .dropdown-item:hover { 167 + background: var(--hover-bg); 168 + } 169 + 170 + .dropdown-divider { 171 + margin: 0; 172 + border: none; 173 + border-top: 1px solid var(--border); 174 + } 175 + 176 + .logout-btn { 177 + color: var(--danger); 178 + font-weight: 500; 87 179 } 88 180 89 181 /* Buttons */ 90 182 button, .btn, .btn-primary, .btn-secondary { 91 183 padding: 0.5rem 1rem; 92 184 background: var(--primary); 93 - color: white; 185 + color:var(--bg); 94 186 border: none; 95 187 border-radius: 4px; 96 188 cursor: pointer; ··· 104 196 opacity: 0.9; 105 197 } 106 198 199 + /* Override nav-links color for primary button */ 200 + .nav-links .btn-primary { 201 + color: var(--bg); 202 + } 203 + 107 204 .btn-secondary { 108 205 background: var(--secondary); 109 206 } 110 207 111 208 .btn-link { 112 209 background: transparent; 113 - color: white; 210 + color:var(--bg); 114 211 text-decoration: underline; 115 212 } 116 213 ··· 132 229 border-radius: 8px; 133 230 padding: 1rem; 134 231 margin-bottom: 1rem; 135 - background: white; 232 + background:var(--bg); 136 233 box-shadow: 0 1px 3px rgba(0,0,0,0.05); 137 234 } 138 235 ··· 286 383 } 287 384 288 385 .settings-section { 289 - background: white; 386 + background:var(--bg); 290 387 border: 1px solid var(--border); 291 388 border-radius: 8px; 292 389 padding: 1.5rem; ··· 468 565 } 469 566 470 567 .login-form { 471 - background: white; 568 + background:var(--bg); 472 569 padding: 2rem; 473 570 border-radius: 8px; 474 571 border: 1px solid var(--border);
+44
pkg/appview/static/js/app.js
··· 72 72 btn.textContent = '▼'; 73 73 } 74 74 } 75 + 76 + // User dropdown menu 77 + document.addEventListener('DOMContentLoaded', () => { 78 + const menuBtn = document.getElementById('user-menu-btn'); 79 + const dropdownMenu = document.getElementById('user-dropdown-menu'); 80 + 81 + if (menuBtn && dropdownMenu) { 82 + // Toggle dropdown on button click 83 + menuBtn.addEventListener('click', (e) => { 84 + e.stopPropagation(); 85 + const isExpanded = menuBtn.getAttribute('aria-expanded') === 'true'; 86 + 87 + if (isExpanded) { 88 + closeDropdown(); 89 + } else { 90 + openDropdown(); 91 + } 92 + }); 93 + 94 + // Close dropdown when clicking outside 95 + document.addEventListener('click', (e) => { 96 + if (!menuBtn.contains(e.target) && !dropdownMenu.contains(e.target)) { 97 + closeDropdown(); 98 + } 99 + }); 100 + 101 + // Close dropdown on Escape key 102 + document.addEventListener('keydown', (e) => { 103 + if (e.key === 'Escape') { 104 + closeDropdown(); 105 + } 106 + }); 107 + 108 + function openDropdown() { 109 + menuBtn.setAttribute('aria-expanded', 'true'); 110 + dropdownMenu.removeAttribute('hidden'); 111 + } 112 + 113 + function closeDropdown() { 114 + menuBtn.setAttribute('aria-expanded', 'false'); 115 + dropdownMenu.setAttribute('hidden', ''); 116 + } 117 + } 118 + });
+21 -6
pkg/appview/templates/components/nav.html
··· 12 12 13 13 <div class="nav-links"> 14 14 {{ if .User }} 15 - <a href="/images">Your Images</a> 16 - <span class="user-handle">@{{ .User.Handle }}</span> 17 - <a href="/settings" class="settings-icon" title="Settings">⚙️</a> 18 - <form action="/auth/logout" method="POST" style="display: inline;"> 19 - <button type="submit" class="btn-link">Logout</button> 20 - </form> 15 + <div class="user-dropdown"> 16 + <button class="user-menu-btn" id="user-menu-btn" aria-expanded="false" aria-haspopup="true"> 17 + {{ if .User.Avatar }} 18 + <img src="{{ .User.Avatar }}" alt="{{ .User.Handle }}" class="user-avatar"> 19 + {{ else }} 20 + <div class="user-avatar-placeholder">{{ firstChar .User.Handle }}</div> 21 + {{ end }} 22 + <span class="user-handle">@{{ .User.Handle }}</span> 23 + <svg class="dropdown-arrow" width="12" height="12" viewBox="0 0 12 12" fill="currentColor"> 24 + <path d="M6 9L1 4h10z"/> 25 + </svg> 26 + </button> 27 + <div class="dropdown-menu" id="user-dropdown-menu" hidden> 28 + <a href="/images" class="dropdown-item">Your Images</a> 29 + <a href="/settings" class="dropdown-item">Settings</a> 30 + <hr class="dropdown-divider"> 31 + <form action="/auth/logout" method="POST"> 32 + <button type="submit" class="dropdown-item logout-btn">Logout</button> 33 + </form> 34 + </div> 35 + </div> 21 36 {{ else }} 22 37 <a href="/auth/oauth/login?return_to=/" class="btn-primary">Login</a> 23 38 {{ end }}
+96 -181
pkg/appview/templates/pages/settings.html
··· 57 57 <div id="hold-status"></div> 58 58 </section> 59 59 60 - <!-- API Keys Section --> 61 - <section class="settings-section api-keys-section"> 62 - <h2>API Keys</h2> 63 - <p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p> 60 + <!-- Authorized Devices Section --> 61 + <section class="settings-section devices-section"> 62 + <h2>Authorized Devices</h2> 63 + <p>Devices authorized via <code>docker-credential-atcr</code> credential helper.</p> 64 64 65 - <!-- Generate New Key --> 66 - <div class="generate-key"> 67 - <h3>Generate New API Key</h3> 68 - <form id="generate-key-form"> 69 - <div class="form-group"> 70 - <label for="key-name">Key Name:</label> 71 - <input type="text" id="key-name" name="key-name" placeholder="e.g., My Laptop, CI/CD" required> 72 - </div> 73 - <button type="submit" class="btn-primary">Generate Key</button> 74 - </form> 65 + <!-- Setup Instructions --> 66 + <div class="setup-instructions"> 67 + <h3>First Time Setup</h3> 68 + <ol> 69 + <li>Install credential helper: 70 + <pre><code>brew install atcr-credential-helper</code></pre> 71 + (or download from releases) 72 + </li> 73 + <li>Configure Docker to use the helper. Add to <code>~/.docker/config.json</code>: 74 + <pre><code>{ 75 + "credHelpers": { 76 + "{{ .RegistryURL | trimPrefix "http://" | trimPrefix "https://" }}": "atcr" 77 + } 78 + }</code></pre> 79 + </li> 80 + <li>Run any Docker command: 81 + <pre><code>docker pull {{ .RegistryURL | trimPrefix "http://" | trimPrefix "https://" }}/{{ .Profile.Handle }}/myimage</code></pre> 82 + </li> 83 + <li>Browser will open for authorization - click Approve</li> 84 + <li>Done! Device is automatically authorized</li> 85 + </ol> 86 + 87 + <div class="fallback-note"> 88 + <strong>Fallback:</strong> Use app-password with <code>docker login {{ .RegistryURL | trimPrefix "http://" | trimPrefix "https://" }}</code> for quick start (no device tracking) 89 + </div> 75 90 </div> 76 91 77 - <!-- Existing Keys List --> 78 - <div class="keys-list"> 79 - <h3>Your API Keys</h3> 92 + <!-- Devices List --> 93 + <div class="devices-list"> 94 + <h3>Your Authorized Devices</h3> 80 95 <table> 81 96 <thead> 82 97 <tr> 83 - <th>Name</th> 98 + <th>Device Name</th> 99 + <th>IP Address</th> 84 100 <th>Created</th> 85 101 <th>Last Used</th> 86 102 <th>Actions</th> 87 103 </tr> 88 104 </thead> 89 - <tbody id="keys-table"> 90 - <tr><td colspan="4">Loading...</td></tr> 105 + <tbody id="devices-table"> 106 + <tr><td colspan="5">Loading...</td></tr> 91 107 </tbody> 92 108 </table> 93 109 </div> ··· 111 127 </div> 112 128 </main> 113 129 114 - <!-- Modal container for HTMX --> 115 - <div id="modal"></div> 116 - 117 - <!-- API Key Modal (shown once after generation) --> 118 - <div id="key-modal" class="modal hidden"> 119 - <div class="modal-backdrop" onclick="closeKeyModal()"></div> 120 - <div class="modal-content"> 121 - <h3>✓ API Key Generated!</h3> 122 - <p><strong>Copy this key now - it won't be shown again:</strong></p> 123 - <div class="key-display"> 124 - <code id="generated-key"></code> 125 - <button class="btn-secondary" onclick="copyKey()">Copy to Clipboard</button> 126 - </div> 127 - <div class="usage-instructions"> 128 - <h4>Using with Docker:</h4> 129 - <p><strong>Direct login (quick start)</strong></p> 130 - <pre><code>docker login atcr.io -u {{ .Profile.Handle }} -p [paste key here]</code></pre> 131 - <p><strong>Credential helper (if you opened this from configure)</strong></p> 132 - <p>Just paste your handle and this key when prompted in the terminal.</p> 133 - </div> 134 - <button class="btn-primary" onclick="closeKeyModal()">Done</button> 135 - </div> 136 - </div> 137 130 138 131 <script src="/static/js/app.js"></script> 139 132 140 133 <script> 141 - // API Key Management JavaScript 134 + // Device Management JavaScript 142 135 (function() { 143 - // Generate key 144 - document.getElementById('generate-key-form').addEventListener('submit', async (e) => { 145 - e.preventDefault(); 146 - const name = document.getElementById('key-name').value; 147 - 136 + // Load devices 137 + async function loadDevices() { 148 138 try { 149 - const resp = await fetch('/api/keys', { 150 - method: 'POST', 151 - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 152 - body: `name=${encodeURIComponent(name)}` 153 - }); 154 - 139 + const resp = await fetch('/api/devices'); 155 140 if (!resp.ok) { 156 - throw new Error('Failed to generate key'); 141 + throw new Error('Failed to load devices'); 157 142 } 158 143 159 - const data = await resp.json(); 144 + const devices = await resp.json(); 145 + const tbody = document.getElementById('devices-table'); 160 146 161 - // Show key in modal (only time it's available) 162 - document.getElementById('generated-key').textContent = data.key; 163 - document.getElementById('key-modal').classList.remove('hidden'); 164 - 165 - // Clear form 166 - document.getElementById('key-name').value = ''; 167 - 168 - // Refresh keys list 169 - loadKeys(); 170 - } catch (err) { 171 - alert('Error generating key: ' + err.message); 172 - } 173 - }); 174 - 175 - // Copy key to clipboard 176 - window.copyKey = function() { 177 - const key = document.getElementById('generated-key').textContent; 178 - navigator.clipboard.writeText(key).then(() => { 179 - alert('Copied to clipboard!'); 180 - }).catch(err => { 181 - alert('Failed to copy: ' + err.message); 182 - }); 183 - }; 184 - 185 - // Close modal 186 - window.closeKeyModal = function() { 187 - document.getElementById('key-modal').classList.add('hidden'); 188 - }; 189 - 190 - // Load existing keys 191 - async function loadKeys() { 192 - try { 193 - const resp = await fetch('/api/keys'); 194 - if (!resp.ok) { 195 - throw new Error('Failed to load keys'); 196 - } 197 - 198 - const keys = await resp.json(); 199 - const tbody = document.getElementById('keys-table'); 200 - 201 - if (keys.length === 0) { 202 - tbody.innerHTML = '<tr><td colspan="4">No API keys yet. Generate one above!</td></tr>'; 147 + if (devices.length === 0) { 148 + tbody.innerHTML = '<tr><td colspan="5">No authorized devices yet. Follow the setup instructions above!</td></tr>'; 203 149 return; 204 150 } 205 151 206 - tbody.innerHTML = keys.map(key => { 207 - const createdDate = new Date(key.created_at).toLocaleDateString(); 208 - const lastUsed = key.last_used && key.last_used !== '0001-01-01T00:00:00Z' 209 - ? new Date(key.last_used).toLocaleDateString() 152 + tbody.innerHTML = devices.map(device => { 153 + const createdDate = new Date(device.created_at).toLocaleDateString(); 154 + const lastUsed = device.last_used && device.last_used !== '0001-01-01T00:00:00Z' 155 + ? new Date(device.last_used).toLocaleDateString() 210 156 : 'Never'; 211 157 212 158 return ` 213 159 <tr> 214 - <td>${escapeHtml(key.name)}</td> 160 + <td>${escapeHtml(device.name)}</td> 161 + <td>${escapeHtml(device.ip_address || 'Unknown')}</td> 215 162 <td>${createdDate}</td> 216 163 <td>${lastUsed}</td> 217 - <td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td> 164 + <td><button class="btn-danger" onclick="revokeDevice('${device.id}')">Revoke</button></td> 218 165 </tr> 219 166 `; 220 167 }).join(''); 221 168 } catch (err) { 222 - console.error('Error loading keys:', err); 223 - document.getElementById('keys-table').innerHTML = 224 - '<tr><td colspan="4">Error loading keys</td></tr>'; 169 + console.error('Error loading devices:', err); 170 + document.getElementById('devices-table').innerHTML = 171 + '<tr><td colspan="5">Error loading devices</td></tr>'; 225 172 } 226 173 } 227 174 228 - // Delete key 229 - window.deleteKey = async function(id) { 230 - if (!confirm('Are you sure you want to revoke this key? This cannot be undone.')) { 175 + // Revoke device 176 + window.revokeDevice = async function(id) { 177 + if (!confirm('Are you sure you want to revoke this device? This cannot be undone.')) { 231 178 return; 232 179 } 233 180 234 181 try { 235 - const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' }); 182 + const resp = await fetch(`/api/devices/${id}`, { method: 'DELETE' }); 236 183 if (!resp.ok) { 237 - throw new Error('Failed to delete key'); 184 + throw new Error('Failed to revoke device'); 238 185 } 239 - loadKeys(); 186 + loadDevices(); 240 187 } catch (err) { 241 - alert('Error revoking key: ' + err.message); 188 + alert('Error revoking device: ' + err.message); 242 189 } 243 190 }; 244 191 ··· 249 196 return div.innerHTML; 250 197 } 251 198 252 - // Load keys on page load 253 - loadKeys(); 199 + // Load devices on page load 200 + loadDevices(); 201 + 202 + // Refresh devices every 30 seconds (to show new authorizations) 203 + setInterval(loadDevices, 30000); 254 204 })(); 255 205 </script> 256 206 257 207 <style> 258 - /* API Key Modal Styles */ 259 - .modal.hidden { display: none; } 260 - .modal { 261 - position: fixed; 262 - top: 0; 263 - left: 0; 264 - width: 100%; 265 - height: 100%; 266 - display: flex; 267 - align-items: center; 268 - justify-content: center; 269 - z-index: 1000; 270 - } 271 - .modal-backdrop { 272 - position: absolute; 273 - top: 0; 274 - left: 0; 275 - width: 100%; 276 - height: 100%; 277 - background: rgba(0,0,0,0.5); 278 - } 279 - .modal-content { 280 - position: relative; 281 - background: white; 282 - padding: 2rem; 283 - border-radius: 8px; 284 - max-width: 600px; 285 - width: 90%; 286 - box-shadow: 0 4px 6px rgba(0,0,0,0.1); 287 - z-index: 1001; 288 - } 289 - .key-display { 290 - background: #f5f5f5; 291 - padding: 1rem; 208 + /* Devices Section Styles */ 209 + .devices-section .setup-instructions { 292 210 margin: 1rem 0; 211 + padding: 1.5rem; 212 + background: #e3f2fd; 293 213 border-radius: 4px; 294 - border: 1px solid #ddd; 295 214 } 296 - .key-display code { 297 - word-break: break-all; 298 - font-size: 14px; 299 - display: block; 300 - margin-bottom: 1rem; 215 + .devices-section .setup-instructions h3 { 216 + margin-top: 0; 301 217 } 302 - .usage-instructions { 303 - margin-top: 1rem; 304 - padding: 1rem; 305 - background: #e3f2fd; 306 - border-radius: 4px; 218 + .devices-section .setup-instructions ol { 219 + margin-left: 1.5rem; 307 220 } 308 - .usage-instructions h4 { 309 - margin-top: 0; 221 + .devices-section .setup-instructions li { 222 + margin-bottom: 1rem; 310 223 } 311 - .usage-instructions pre { 224 + .devices-section .setup-instructions pre { 312 225 background: #263238; 313 226 color: #aed581; 314 - padding: 1rem; 227 + padding: 0.75rem; 315 228 border-radius: 4px; 316 229 overflow-x: auto; 317 - margin: 0.5rem 0 0 0; 230 + margin: 0.5rem 0; 318 231 } 319 - .usage-instructions code { 232 + .devices-section .setup-instructions code { 320 233 font-family: monospace; 321 234 } 322 - 323 - /* API Keys Section Styles */ 324 - .api-keys-section table { 235 + .devices-section .fallback-note { 236 + margin-top: 1rem; 237 + padding: 1rem; 238 + background: #fff3cd; 239 + border: 1px solid #ffc107; 240 + border-radius: 4px; 241 + } 242 + .devices-section table { 325 243 width: 100%; 326 244 border-collapse: collapse; 327 245 margin-top: 1rem; 328 246 } 329 - .api-keys-section th, 330 - .api-keys-section td { 247 + .devices-section th, 248 + .devices-section td { 331 249 padding: 0.75rem; 332 250 text-align: left; 333 251 border-bottom: 1px solid #ddd; 334 252 } 335 - .api-keys-section th { 253 + .devices-section th { 336 254 background: #f5f5f5; 337 255 font-weight: bold; 338 256 } 339 - .api-keys-section .btn-danger { 257 + .devices-section .btn-danger { 340 258 background: #dc3545; 341 259 color: white; 342 260 border: none; ··· 344 262 border-radius: 4px; 345 263 cursor: pointer; 346 264 } 347 - .api-keys-section .btn-danger:hover { 265 + .devices-section .btn-danger:hover { 348 266 background: #c82333; 349 267 } 350 - .generate-key { 351 - margin: 1rem 0; 352 - padding: 1rem; 353 - background: #f8f9fa; 354 - border-radius: 4px; 268 + .devices-list { 269 + margin-top: 2rem; 355 270 } 356 271 </style> 357 272 </body>
+137
pkg/atproto/client.go
··· 433 433 434 434 return result.Records, result.Cursor, nil 435 435 } 436 + 437 + // ActorProfile represents a Bluesky actor profile (from AppView) 438 + type ActorProfile struct { 439 + DID string `json:"did"` 440 + Handle string `json:"handle"` 441 + DisplayName string `json:"displayName,omitempty"` 442 + Description string `json:"description,omitempty"` 443 + Avatar string `json:"avatar,omitempty"` // CDN URL from AppView 444 + } 445 + 446 + // ProfileRecord represents the app.bsky.actor.profile record (from PDS) 447 + type ProfileRecord struct { 448 + DisplayName string `json:"displayName,omitempty"` 449 + Description string `json:"description,omitempty"` 450 + Avatar *ATProtoBlobRef `json:"avatar,omitempty"` // Blob reference 451 + Banner *ATProtoBlobRef `json:"banner,omitempty"` 452 + CreatedAt string `json:"createdAt,omitempty"` 453 + } 454 + 455 + // GetActorProfile fetches an actor's profile from their PDS 456 + // The actor parameter can be a DID or handle 457 + func (c *Client) GetActorProfile(ctx context.Context, actor string) (*ActorProfile, error) { 458 + // Use indigo API client (OAuth with DPoP) 459 + if c.useIndigoClient && c.indigoClient != nil { 460 + params := map[string]any{ 461 + "actor": actor, 462 + } 463 + 464 + var profile ActorProfile 465 + err := c.indigoClient.Get(ctx, "app.bsky.actor.getProfile", params, &profile) 466 + if err != nil { 467 + return nil, fmt.Errorf("getProfile failed: %w", err) 468 + } 469 + return &profile, nil 470 + } 471 + 472 + // Basic Auth (app passwords) 473 + url := fmt.Sprintf("%s/xrpc/app.bsky.actor.getProfile?actor=%s", c.pdsEndpoint, actor) 474 + 475 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 476 + if err != nil { 477 + return nil, err 478 + } 479 + 480 + // This endpoint typically doesn't require auth for public profiles 481 + if c.accessToken != "" { 482 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 483 + } 484 + 485 + resp, err := c.httpClient.Do(req) 486 + if err != nil { 487 + return nil, fmt.Errorf("failed to get profile: %w", err) 488 + } 489 + defer resp.Body.Close() 490 + 491 + if resp.StatusCode == http.StatusNotFound { 492 + return nil, fmt.Errorf("profile not found") 493 + } 494 + 495 + if resp.StatusCode != http.StatusOK { 496 + bodyBytes, _ := io.ReadAll(resp.Body) 497 + return nil, fmt.Errorf("get profile failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 498 + } 499 + 500 + var profile ActorProfile 501 + if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { 502 + return nil, fmt.Errorf("failed to decode profile: %w", err) 503 + } 504 + 505 + return &profile, nil 506 + } 507 + 508 + // GetProfileRecord fetches the app.bsky.actor.profile record from PDS 509 + // This returns the raw profile record with blob references (not CDN URLs) 510 + func (c *Client) GetProfileRecord(ctx context.Context, did string) (*ProfileRecord, error) { 511 + // Use indigo API client (OAuth with DPoP) 512 + if c.useIndigoClient && c.indigoClient != nil { 513 + params := map[string]any{ 514 + "repo": did, 515 + "collection": "app.bsky.actor.profile", 516 + "rkey": "self", 517 + } 518 + 519 + var result struct { 520 + Value ProfileRecord `json:"value"` 521 + } 522 + 523 + err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result) 524 + if err != nil { 525 + return nil, fmt.Errorf("getRecord failed: %w", err) 526 + } 527 + return &result.Value, nil 528 + } 529 + 530 + // Basic Auth (app passwords) 531 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", 532 + c.pdsEndpoint, did) 533 + 534 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 535 + if err != nil { 536 + return nil, err 537 + } 538 + 539 + if c.accessToken != "" { 540 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 541 + } 542 + 543 + resp, err := c.httpClient.Do(req) 544 + if err != nil { 545 + return nil, fmt.Errorf("failed to get profile record: %w", err) 546 + } 547 + defer resp.Body.Close() 548 + 549 + if resp.StatusCode == http.StatusNotFound { 550 + return nil, fmt.Errorf("profile record not found") 551 + } 552 + 553 + if resp.StatusCode != http.StatusOK { 554 + bodyBytes, _ := io.ReadAll(resp.Body) 555 + return nil, fmt.Errorf("get profile record failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 556 + } 557 + 558 + var result struct { 559 + Value ProfileRecord `json:"value"` 560 + } 561 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 562 + return nil, fmt.Errorf("failed to decode profile record: %w", err) 563 + } 564 + 565 + return &result.Value, nil 566 + } 567 + 568 + // BlobCDNURL constructs an imgs.blue CDN URL for a blob 569 + // The imgs.blue service can serve blobs using DID or handle 570 + func BlobCDNURL(didOrHandle, cid string) string { 571 + return fmt.Sprintf("https://imgs.blue/%s/%s", didOrHandle, cid) 572 + }
+7 -5
pkg/auth/oauth/client.go
··· 88 88 return a.clientApp 89 89 } 90 90 91 + // Directory returns the identity directory used by the OAuth app 92 + func (a *App) Directory() identity.Directory { 93 + return a.directory 94 + } 95 + 91 96 // ClientID generates the OAuth client ID for ATCR 92 97 func ClientID(baseURL string) string { 93 98 return ClientIDWithScopes(baseURL, GetDefaultScopes()) ··· 115 120 func GetDefaultScopes() []string { 116 121 return []string{ 117 122 "atproto", 118 - "transition:generic.full", 119 123 "blob:application/vnd.docker.distribution.manifest.v2+json", 120 - fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection), 121 - fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection), 122 - fmt.Sprintf("repo:%s?action=create", atproto.TagCollection), 123 - fmt.Sprintf("repo:%s?action=update", atproto.TagCollection), 124 + fmt.Sprintf("repo:%s", atproto.ManifestCollection), 125 + fmt.Sprintf("repo:%s", atproto.TagCollection), 124 126 } 125 127 }
+14
pkg/auth/oauth/refresher.go
··· 125 125 delete(r.sessions, did) 126 126 r.mu.Unlock() 127 127 } 128 + 129 + // GetSessionID returns the sessionID for a cached session 130 + // Returns empty string if session not cached 131 + func (r *Refresher) GetSessionID(did string) string { 132 + r.mu.RLock() 133 + defer r.mu.RUnlock() 134 + 135 + cached, ok := r.sessions[did] 136 + if !ok || cached == nil { 137 + return "" 138 + } 139 + 140 + return cached.SessionID 141 + }
+125 -15
pkg/auth/oauth/server.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 4 6 "fmt" 5 7 "html/template" 6 8 "net/http" 7 9 "time" 10 + 11 + "atcr.io/pkg/appview/db" 12 + "atcr.io/pkg/atproto" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 8 14 ) 9 15 10 16 // UISessionStore is the interface for UI session management ··· 12 18 Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) 13 19 } 14 20 21 + // UserStore is the interface for user management 22 + type UserStore interface { 23 + UpsertUser(did, handle, pdsEndpoint, avatar string) error 24 + } 25 + 15 26 // Server handles OAuth authorization for the AppView 16 27 type Server struct { 17 28 app *App 18 29 refresher *Refresher 19 30 uiSessionStore UISessionStore 31 + db *sql.DB 20 32 } 21 33 22 34 // NewServer creates a new OAuth server ··· 34 46 // SetUISessionStore sets the UI session store for web login 35 47 func (s *Server) SetUISessionStore(store UISessionStore) { 36 48 s.uiSessionStore = store 49 + } 50 + 51 + // SetDatabase sets the database for user management 52 + func (s *Server) SetDatabase(db *sql.DB) { 53 + s.db = db 37 54 } 38 55 39 56 // ServeAuthorize handles GET /auth/oauth/authorize ··· 107 124 handle = did // Fallback to DID if resolution fails 108 125 } 109 126 127 + // Fetch user's Bluesky profile (including avatar) and store in database 128 + if s.db != nil { 129 + s.fetchAndStoreAvatar(r.Context(), did, sessionID, handle, sessionData.HostURL) 130 + } 131 + 110 132 // Check if this is a UI login (has oauth_return_to cookie) 111 133 if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { 112 134 // Create UI session (30 days to match OAuth refresh token lifetime) 113 - uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) 114 - if err != nil { 115 - s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 116 - return 135 + // Store OAuth sessionID so we can resume it on next login 136 + if store, ok := s.uiSessionStore.(interface { 137 + CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) 138 + }); ok { 139 + uiSessionID, err := store.CreateWithOAuth(did, handle, sessionData.HostURL, sessionID, 30*24*time.Hour) 140 + if err != nil { 141 + s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 142 + return 143 + } 144 + // Set UI session cookie and redirect (code below) 145 + http.SetCookie(w, &http.Cookie{ 146 + Name: "atcr_session", 147 + Value: uiSessionID, 148 + Path: "/", 149 + MaxAge: 30 * 86400, // 30 days 150 + HttpOnly: true, 151 + Secure: true, 152 + SameSite: http.SameSiteLaxMode, 153 + }) 154 + } else { 155 + // Fallback for stores that don't support OAuth sessionID 156 + uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) 157 + if err != nil { 158 + s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 159 + return 160 + } 161 + // Set UI session cookie 162 + http.SetCookie(w, &http.Cookie{ 163 + Name: "atcr_session", 164 + Value: uiSessionID, 165 + Path: "/", 166 + MaxAge: 30 * 86400, // 30 days 167 + HttpOnly: true, 168 + Secure: true, 169 + SameSite: http.SameSiteLaxMode, 170 + }) 117 171 } 118 172 119 - // Set UI session cookie 120 - http.SetCookie(w, &http.Cookie{ 121 - Name: "atcr_session", 122 - Value: uiSessionID, 123 - Path: "/", 124 - MaxAge: 30 * 86400, // 30 days 125 - HttpOnly: true, 126 - Secure: true, 127 - SameSite: http.SameSiteLaxMode, 128 - }) 129 - 130 173 // Clear the return_to cookie 131 174 http.SetCookie(w, &http.Cookie{ 132 175 Name: "oauth_return_to", ··· 178 221 if err := tmpl.Execute(w, data); err != nil { 179 222 http.Error(w, "failed to render template", http.StatusInternalServerError) 180 223 } 224 + } 225 + 226 + // fetchAndStoreAvatar fetches the user's Bluesky profile and stores avatar in database 227 + func (s *Server) fetchAndStoreAvatar(ctx context.Context, did, sessionID, handle, pdsEndpoint string) { 228 + fmt.Printf("DEBUG [oauth/server]: Fetching avatar for DID=%s from PDS=%s\n", did, pdsEndpoint) 229 + 230 + // Parse DID for session resume 231 + didParsed, err := syntax.ParseDID(did) 232 + if err != nil { 233 + fmt.Printf("WARNING [oauth/server]: Failed to parse DID %s: %v\n", did, err) 234 + return 235 + } 236 + 237 + // Resume OAuth session to get authenticated client 238 + session, err := s.app.ResumeSession(ctx, didParsed, sessionID) 239 + if err != nil { 240 + fmt.Printf("WARNING [oauth/server]: Failed to resume session for DID=%s: %v\n", did, err) 241 + // Fallback: update user without avatar 242 + _ = db.UpsertUser(s.db, &db.User{ 243 + DID: did, 244 + Handle: handle, 245 + PDSEndpoint: pdsEndpoint, 246 + Avatar: "", 247 + LastSeen: time.Now(), 248 + }) 249 + return 250 + } 251 + 252 + // Create authenticated atproto client using the indigo session's API client 253 + client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, session.APIClient()) 254 + 255 + // Fetch user's profile record from PDS (contains blob references) 256 + profileRecord, err := client.GetProfileRecord(ctx, did) 257 + if err != nil { 258 + fmt.Printf("WARNING [oauth/server]: Failed to fetch profile record for DID=%s: %v\n", did, err) 259 + // Still update user without avatar 260 + _ = db.UpsertUser(s.db, &db.User{ 261 + DID: did, 262 + Handle: handle, 263 + PDSEndpoint: pdsEndpoint, 264 + Avatar: "", 265 + LastSeen: time.Now(), 266 + }) 267 + return 268 + } 269 + 270 + // Construct avatar URL from blob CID using imgs.blue CDN 271 + var avatarURL string 272 + if profileRecord.Avatar != nil && profileRecord.Avatar.Ref.Link != "" { 273 + avatarURL = atproto.BlobCDNURL(did, profileRecord.Avatar.Ref.Link) 274 + fmt.Printf("DEBUG [oauth/server]: Constructed avatar URL: %s\n", avatarURL) 275 + } 276 + 277 + // Store user with avatar in database 278 + err = db.UpsertUser(s.db, &db.User{ 279 + DID: did, 280 + Handle: handle, 281 + PDSEndpoint: pdsEndpoint, 282 + Avatar: avatarURL, 283 + LastSeen: time.Now(), 284 + }) 285 + if err != nil { 286 + fmt.Printf("WARNING [oauth/server]: Failed to store user in database: %v\n", err) 287 + return 288 + } 289 + 290 + fmt.Printf("DEBUG [oauth/server]: Stored user with avatar for DID=%s\n", did) 181 291 } 182 292 183 293 // HTML templates
+13 -13
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/apikey" 13 + "atcr.io/pkg/appview/device" 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 - apiKeyStore *apikey.Store // For validating API keys 23 + deviceStore *device.Store // 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, apiKeyStore *apikey.Store, defaultHoldEndpoint string) *Handler { 28 + func NewHandler(issuer *Issuer, deviceStore *device.Store, defaultHoldEndpoint string) *Handler { 29 29 return &Handler{ 30 30 issuer: issuer, 31 31 validator: atproto.NewSessionValidator(), 32 - apiKeyStore: apiKeyStore, 32 + deviceStore: deviceStore, 33 33 defaultHoldEndpoint: defaultHoldEndpoint, 34 34 } 35 35 } ··· 83 83 var handle string 84 84 var accessToken string 85 85 86 - // 1. Check if it's an API key (starts with "atcr_") 87 - if strings.HasPrefix(password, "atcr_") { 88 - apiKey, err := h.apiKeyStore.Validate(password) 86 + // 1. Check if it's a device secret (starts with "atcr_device_") 87 + if strings.HasPrefix(password, "atcr_device_") { 88 + device, err := h.deviceStore.ValidateDeviceSecret(password) 89 89 if err != nil { 90 - fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err) 90 + fmt.Printf("DEBUG [token/handler]: Device secret validation failed: %v\n", err) 91 91 w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 92 92 http.Error(w, "authentication failed", http.StatusUnauthorized) 93 93 return 94 94 } 95 95 96 - did = apiKey.DID 97 - handle = apiKey.Handle 98 - fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle) 96 + did = device.DID 97 + handle = device.Handle 98 + fmt.Printf("DEBUG [token/handler]: Device secret validated for DID=%s, handle=%s\n", did, handle) 99 99 100 - // API key is linked to OAuth session 100 + // Device is linked to OAuth session via DID 101 101 // OAuth refresher will provide access token when needed via middleware 102 102 } else { 103 103 // 2. Try app password (direct PDS authentication) 104 - fmt.Printf("DEBUG [token/handler]: Not an API key, trying app password for %s\n", username) 104 + fmt.Printf("DEBUG [token/handler]: Not a device secret, trying app password for %s\n", username) 105 105 did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 106 106 if err != nil { 107 107 fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)