diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go index dae7880..0436eed 100644 --- a/cmd/credential-helper/main.go +++ b/cmd/credential-helper/main.go @@ -1,13 +1,16 @@ package main import ( + "bytes" "encoding/json" "fmt" + "io" + "net/http" "os" + "os/exec" "path/filepath" - "strings" - - "atcr.io/pkg/auth/oauth" + "runtime" + "time" ) const ( @@ -15,11 +18,11 @@ const ( defaultAppViewURL = "http://127.0.0.1:5000" ) -// CredentialStore represents the stored API key credentials -type CredentialStore struct { - APIKey string `json:"api_key"` - Handle string `json:"handle"` - AppViewURL string `json:"appview_url"` +// DeviceConfig represents the stored device configuration +type DeviceConfig struct { + Handle string `json:"handle"` + DeviceSecret string `json:"device_secret"` + AppViewURL string `json:"appview_url"` } // Docker credential helper protocol @@ -32,9 +35,34 @@ type Credentials struct { Secret string `json:"Secret,omitempty"` } +// Device authorization API types + +type DeviceCodeRequest struct { + DeviceName string `json:"device_name"` +} + +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type DeviceTokenRequest struct { + DeviceCode string `json:"device_code"` +} + +type DeviceTokenResponse struct { + DeviceSecret string `json:"device_secret,omitempty"` + Handle string `json:"handle,omitempty"` + DID string `json:"did,omitempty"` + Error string `json:"error,omitempty"` +} + func main() { if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr \n") + fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr \n") os.Exit(1) } @@ -47,13 +75,6 @@ func main() { handleStore() case "erase": handleErase() - case "configure": - // Optional handle argument - var handle string - if len(os.Args) > 2 { - handle = os.Args[2] - } - handleConfigure(handle) default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) os.Exit(1) @@ -69,22 +90,34 @@ func handleGet() { os.Exit(1) } - // Load credentials from storage - credsPath := getCredentialsPath() - storedCreds, err := loadCredentials(credsPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err) - fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n") - os.Exit(1) + // Load device configuration + configPath := getConfigPath() + deviceConfig, err := loadDeviceConfig(configPath) + if err != nil || deviceConfig.DeviceSecret == "" { + // First time - trigger device authorization + fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n") + + deviceConfig, err = authorizeDevice() + if err != nil { + fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err) + fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login atcr.io' with your ATProto app-password\n") + os.Exit(1) + } + + // Save device configuration + if err := saveDeviceConfig(configPath, deviceConfig); err != nil { + fmt.Fprintf(os.Stderr, "Failed to save device config: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "✓ Device authorized successfully!\n") } // Return credentials for Docker - // Docker will send these as Basic Auth to /auth/token - // The token handler will validate the API key and issue a registry JWT creds := Credentials{ ServerURL: serverURL, - Username: storedCreds.Handle, // Use handle as username - Secret: storedCreds.APIKey, // API key as password + Username: deviceConfig.Handle, + Secret: deviceConfig.DeviceSecret, } if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { @@ -101,9 +134,9 @@ func handleStore() { os.Exit(1) } - // For OAuth flow, we don't actually store credentials from docker login - // The credentials are managed through the OAuth flow - // This is a no-op for us + // This is a no-op for the device auth flow + // Users should use the automatic device authorization, not docker login + // If they use docker login with app-password, that goes through /auth/token directly } // handleErase removes stored credentials @@ -115,81 +148,103 @@ func handleErase() { os.Exit(1) } - // Remove credentials file - credsPath := getCredentialsPath() - if err := os.Remove(credsPath); err != nil && !os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error removing credentials: %v\n", err) + // Remove device configuration file + configPath := getConfigPath() + if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error removing device config: %v\n", err) os.Exit(1) } } -// handleConfigure prompts for API key and saves credentials -func handleConfigure(handle string) { - fmt.Println("ATCR Credential Helper Configuration") - fmt.Println("=====================================") - fmt.Println() - fmt.Println("You need an API key from the ATCR web UI.") - fmt.Println() - - // Get AppView URL from environment or use default +// authorizeDevice performs the device authorization flow +func authorizeDevice() (*DeviceConfig, error) { + // Get AppView URL appViewURL := os.Getenv("ATCR_APPVIEW_URL") if appViewURL == "" { appViewURL = defaultAppViewURL } - // Auto-open settings page - settingsURL := appViewURL + "/settings" - fmt.Printf("Opening settings page: %s\n", settingsURL) - fmt.Println("Log in and generate an API key if you haven't already.") - fmt.Println() - - if err := oauth.OpenBrowser(settingsURL); err != nil { - fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL) + // Get device name (hostname) + deviceName, err := os.Hostname() + if err != nil { + deviceName = "Unknown Device" } - // Prompt for credentials - if handle == "" { - fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") - if _, err := fmt.Scanln(&handle); err != nil { - fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err) - os.Exit(1) + // 1. Request device code + fmt.Fprintf(os.Stderr, "Requesting device authorization...\n") + + reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName}) + resp, err := http.Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to request device code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("device code request failed: %s", string(body)) + } + + var codeResp DeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { + return nil, fmt.Errorf("failed to decode device code response: %w", err) + } + + // 2. Open browser for user to approve + verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode + + fmt.Fprintf(os.Stderr, "\nOpening browser for device authorization...\n") + fmt.Fprintf(os.Stderr, "User code: %s\n", codeResp.UserCode) + fmt.Fprintf(os.Stderr, "\nIf browser doesn't open, visit: %s\n\n", verificationURL) + + if err := openBrowser(verificationURL); err != nil { + fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err) + } + + fmt.Fprintf(os.Stderr, "Waiting for authorization...\n") + + // 3. Poll for authorization completion + pollInterval := time.Duration(codeResp.Interval) * time.Second + timeout := time.Duration(codeResp.ExpiresIn) * time.Second + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + + // Poll token endpoint + tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) + tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) + if err != nil { + fmt.Fprintf(os.Stderr, "Poll failed: %v\n", err) + continue } - } else { - fmt.Printf("Using handle: %s\n", handle) + + var tokenResult DeviceTokenResponse + json.NewDecoder(tokenResp.Body).Decode(&tokenResult) + tokenResp.Body.Close() + + if tokenResult.Error == "authorization_pending" { + // Still waiting + continue + } + + if tokenResult.Error != "" { + return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) + } + + // Success! + return &DeviceConfig{ + Handle: tokenResult.Handle, + DeviceSecret: tokenResult.DeviceSecret, + AppViewURL: appViewURL, + }, nil } - fmt.Print("Enter your API key (from settings page): ") - var apiKey string - if _, err := fmt.Scanln(&apiKey); err != nil { - fmt.Fprintf(os.Stderr, "Error reading API key: %v\n", err) - os.Exit(1) - } - - // Validate key format - if !strings.HasPrefix(apiKey, "atcr_") { - fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n") - os.Exit(1) - } - - // Save credentials - creds := &CredentialStore{ - Handle: handle, - APIKey: apiKey, - AppViewURL: appViewURL, - } - - if err := saveCredentials(getCredentialsPath(), creds); err != nil { - fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err) - os.Exit(1) - } - - fmt.Println() - fmt.Println("✓ Configuration complete!") - fmt.Println("You can now use docker push/pull with atcr.io") + return nil, fmt.Errorf("authorization timeout") } -// getCredentialsPath returns the path to the credentials file -func getCredentialsPath() string { +// getConfigPath returns the path to the device configuration file +func getConfigPath() string { homeDir, err := os.UserHomeDir() if err != nil { fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) @@ -202,34 +257,48 @@ func getCredentialsPath() string { os.Exit(1) } - return filepath.Join(atcrDir, "credentials.json") + return filepath.Join(atcrDir, "device.json") } -// loadCredentials loads the credentials from disk -func loadCredentials(path string) (*CredentialStore, error) { +// loadDeviceConfig loads the device configuration from disk +func loadDeviceConfig(path string) (*DeviceConfig, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read credentials file: %w", err) + return nil, err } - var creds CredentialStore - if err := json.Unmarshal(data, &creds); err != nil { - return nil, fmt.Errorf("failed to parse credentials file: %w", err) + var config DeviceConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err } - return &creds, nil + return &config, nil } -// saveCredentials saves the credentials to disk -func saveCredentials(path string, creds *CredentialStore) error { - data, err := json.MarshalIndent(creds, "", " ") +// saveDeviceConfig saves the device configuration to disk +func saveDeviceConfig(path string, config *DeviceConfig) error { + data, err := json.MarshalIndent(config, "", " ") if err != nil { - return fmt.Errorf("failed to marshal credentials: %w", err) + return err } - if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("failed to write credentials file: %w", err) + return os.WriteFile(path, data, 0600) +} + +// openBrowser opens the specified URL in the default browser +func openBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform") } - return nil + return cmd.Start() } diff --git a/cmd/registry/serve.go b/cmd/registry/serve.go index c10e473..f0c24f9 100644 --- a/cmd/registry/serve.go +++ b/cmd/registry/serve.go @@ -23,8 +23,8 @@ import ( // UI components "atcr.io/pkg/appview" - "atcr.io/pkg/appview/apikey" "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/device" uihandlers "atcr.io/pkg/appview/handlers" "atcr.io/pkg/appview/jetstream" appmiddleware "atcr.io/pkg/appview/middleware" @@ -92,13 +92,22 @@ func serveRegistry(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to create OAuth store: %w", err) } - // 2. Create API key store - apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json") - apiKeyStore, err := apikey.NewStore(apiKeyStorePath) + // 2. Create device store + deviceStorePath := filepath.Join(filepath.Dir(storagePath), "devices.json") + deviceStore, err := device.NewStore(deviceStorePath) if err != nil { - return fmt.Errorf("failed to create API key store: %w", err) + return fmt.Errorf("failed to create device store: %w", err) } - fmt.Printf("Using API key storage path: %s\n", apiKeyStorePath) + fmt.Printf("Using device storage path: %s\n", deviceStorePath) + + // Start background cleanup for expired pending authorizations + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + deviceStore.CleanupExpired() + } + }() // 3. Get base URL from config or environment baseURL := os.Getenv("ATCR_BASE_URL") @@ -127,7 +136,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error { middleware.SetGlobalRefresher(refresher) // 7. Initialize UI components (get session store for OAuth integration) - uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL, apiKeyStore) + uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, oauthApp, refresher, baseURL, deviceStore) // 8. Create OAuth server oauthServer := oauth.NewServer(oauthApp) @@ -137,6 +146,10 @@ func serveRegistry(cmd *cobra.Command, args []string) error { if uiSessionStore != nil { oauthServer.SetUISessionStore(uiSessionStore) } + // Connect database for user avatar management + if uiDatabase != nil { + oauthServer.SetDatabase(uiDatabase) + } // 8. Initialize auth keys and create token issuer var issuer *token.Issuer @@ -187,12 +200,23 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Extract default hold endpoint from middleware config defaultHoldEndpoint := extractDefaultHoldEndpoint(config) - // Basic Auth token endpoint (supports API keys and app passwords) - tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint) + // Basic Auth token endpoint (supports device secrets and app passwords) + tokenHandler := token.NewHandler(issuer, deviceStore, defaultHoldEndpoint) tokenHandler.RegisterRoutes(mux) + // Device authorization endpoints (public) + mux.Handle("/auth/device/code", &uihandlers.DeviceCodeHandler{ + Store: deviceStore, + AppViewBaseURL: baseURL, + }) + mux.Handle("/auth/device/token", &uihandlers.DeviceTokenHandler{ + Store: deviceStore, + }) + fmt.Printf("Auth endpoints enabled:\n") - fmt.Printf(" - Basic Auth: /auth/token (API keys + app passwords)\n") + fmt.Printf(" - Basic Auth: /auth/token (device secrets + app passwords)\n") + fmt.Printf(" - Device Auth: /auth/device/code\n") + fmt.Printf(" - Device Auth: /auth/device/token\n") fmt.Printf(" - OAuth: /auth/oauth/authorize\n") fmt.Printf(" - OAuth: /auth/oauth/callback\n") } @@ -326,7 +350,7 @@ func extractDefaultHoldEndpoint(config *configuration.Configuration) string { } // initializeUI initializes the web UI components -func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string, apiKeyStore *apikey.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { +func initializeUI(config *configuration.Configuration, oauthApp *oauth.App, refresher *oauth.Refresher, baseURL string, deviceStore *device.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { // Check if UI is enabled (optional configuration) uiEnabled := os.Getenv("ATCR_UI_ENABLED") if uiEnabled == "false" { @@ -386,10 +410,14 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe Templates: templates, }).Methods("GET") - router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{}).Methods("POST") + router.Handle("/auth/oauth/login", &uihandlers.LoginSubmitHandler{ + Refresher: refresher, + Directory: oauthApp.Directory(), + SessionStore: sessionStore, + }).Methods("POST") // Public routes (with optional auth for navbar) - router.Handle("/", appmiddleware.OptionalAuth(sessionStore)( + router.Handle("/", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.HomeHandler{ DB: database, Templates: templates, @@ -397,7 +425,7 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe }, )).Methods("GET") - router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore)( + router.Handle("/api/recent-pushes", appmiddleware.OptionalAuth(sessionStore, database)( &uihandlers.RecentPushesHandler{ DB: database, Templates: templates, @@ -407,7 +435,7 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe // Authenticated routes authRouter := router.NewRoute().Subrouter() - authRouter.Use(appmiddleware.RequireAuth(sessionStore)) + authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) authRouter.Handle("/images", &uihandlers.ImagesHandler{ DB: database, @@ -416,8 +444,9 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe }).Methods("GET") authRouter.Handle("/settings", &uihandlers.SettingsHandler{ - Templates: templates, - Refresher: refresher, + Templates: templates, + Refresher: refresher, + RegistryURL: baseURL, }).Methods("GET") authRouter.Handle("/api/profile/default-hold", &uihandlers.UpdateDefaultHoldHandler{ @@ -432,17 +461,26 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe DB: database, }).Methods("DELETE") - // API key management routes - authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{ - Store: apiKeyStore, - }).Methods("POST") - - authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{ - Store: apiKeyStore, + // Device approval page (authenticated) + authRouter.Handle("/device", &uihandlers.DeviceApprovalPageHandler{ + Store: deviceStore, + SessionStore: sessionStore, }).Methods("GET") - authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{ - Store: apiKeyStore, + authRouter.Handle("/device/approve", &uihandlers.DeviceApproveHandler{ + Store: deviceStore, + SessionStore: sessionStore, + }).Methods("POST") + + // Device management routes + authRouter.Handle("/api/devices", &uihandlers.ListDevicesHandler{ + Store: deviceStore, + SessionStore: sessionStore, + }).Methods("GET") + + authRouter.Handle("/api/devices/{id}", &uihandlers.RevokeDeviceHandler{ + Store: deviceStore, + SessionStore: sessionStore, }).Methods("DELETE") // Logout endpoint @@ -484,6 +522,7 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe if err != nil { fmt.Printf("Warning: Failed to create backfill worker: %v\n", err) } else { + // Run initial backfill go func() { fmt.Printf("Backfill: Starting sync-based backfill from %s...\n", relayEndpoint) if err := backfillWorker.Start(context.Background()); err != nil { @@ -492,6 +531,32 @@ func initializeUI(config *configuration.Configuration, refresher *oauth.Refreshe fmt.Println("Backfill: Completed successfully!") } }() + + // Start periodic backfill scheduler + backfillInterval := os.Getenv("ATCR_BACKFILL_INTERVAL") + if backfillInterval == "" { + backfillInterval = "1h" // Default to 1 hour + } + interval, err := time.ParseDuration(backfillInterval) + if err != nil { + fmt.Printf("Warning: Invalid ATCR_BACKFILL_INTERVAL '%s', using default 1h: %v\n", backfillInterval, err) + interval = time.Hour + } + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + fmt.Printf("Backfill: Starting periodic backfill (runs every %s)...\n", interval) + if err := backfillWorker.Start(context.Background()); err != nil { + fmt.Printf("Backfill: Periodic backfill finished with error: %v\n", err) + } else { + fmt.Println("Backfill: Periodic backfill completed successfully!") + } + } + }() + fmt.Printf("Backfill: Periodic scheduler started (interval: %s)\n", interval) } } diff --git a/docker-compose.yml b/docker-compose.yml index de510fb..00fa286 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,9 @@ services: # UI database (firehose cache for web interface) - atcr-ui:/var/lib/atcr restart: unless-stopped + dns: + - 8.8.8.8 + - 1.1.1.1 networks: atcr-network: ipv4_address: 172.28.0.2 @@ -46,6 +49,9 @@ services: volumes: - atcr-hold:/var/lib/atcr/hold restart: unless-stopped + dns: + - 8.8.8.8 + - 1.1.1.1 networks: atcr-network: ipv4_address: 172.28.0.3 diff --git a/pkg/appview/apikey/store.go b/pkg/appview/apikey/store.go deleted file mode 100644 index ea4c7cd..0000000 --- a/pkg/appview/apikey/store.go +++ /dev/null @@ -1,249 +0,0 @@ -package apikey - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "sync" - "time" - - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" -) - -// APIKey represents a user's API key -type APIKey struct { - ID string `json:"id"` // UUID - KeyHash string `json:"key_hash"` // bcrypt hash - DID string `json:"did"` // Owner's DID - Handle string `json:"handle"` // Owner's handle - Name string `json:"name"` // User-provided name - CreatedAt time.Time `json:"created_at"` - LastUsed time.Time `json:"last_used"` -} - -// Store manages API keys -type Store struct { - mu sync.RWMutex - keys map[string]*APIKey // keyHash -> APIKey - byDID map[string][]string // DID -> []keyHash - filePath string // /var/lib/atcr/api-keys.json -} - -// persistentData is the structure saved to disk -type persistentData struct { - Keys []*APIKey `json:"keys"` -} - -// NewStore creates a new API key store -func NewStore(filePath string) (*Store, error) { - s := &Store{ - keys: make(map[string]*APIKey), - byDID: make(map[string][]string), - filePath: filePath, - } - - // Load existing keys from file - if err := s.load(); err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to load API keys: %w", err) - } - - return s, nil -} - -// Generate creates a new API key and returns the plaintext key (shown once) -func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error) { - // Generate 32 random bytes - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", "", fmt.Errorf("failed to generate random bytes: %w", err) - } - - // Format: atcr_ - key = "atcr_" + base64.RawURLEncoding.EncodeToString(b) - - // Hash for storage - keyHashBytes, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) - if err != nil { - return "", "", fmt.Errorf("failed to hash key: %w", err) - } - keyHash := string(keyHashBytes) - - // Generate ID - keyID = uuid.New().String() - - apiKey := &APIKey{ - ID: keyID, - KeyHash: keyHash, - DID: did, - Handle: handle, - Name: name, - CreatedAt: time.Now(), - LastUsed: time.Time{}, // Never used yet - } - - s.mu.Lock() - s.keys[keyHash] = apiKey - s.byDID[did] = append(s.byDID[did], keyHash) - s.mu.Unlock() - - if err := s.save(); err != nil { - return "", "", fmt.Errorf("failed to save keys: %w", err) - } - - // Return plaintext key (only time it's available) - return key, keyID, nil -} - -// Validate checks if an API key is valid and returns the associated data -func (s *Store) Validate(key string) (*APIKey, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - // Try to match against all stored hashes - for hash, apiKey := range s.keys { - if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil { - // Update last used asynchronously - go s.UpdateLastUsed(hash) - - // Return a copy to prevent external modifications - keyCopy := *apiKey - return &keyCopy, nil - } - } - - return nil, fmt.Errorf("invalid API key") -} - -// List returns all API keys for a DID (without plaintext keys) -func (s *Store) List(did string) []*APIKey { - s.mu.RLock() - defer s.mu.RUnlock() - - keyHashes, ok := s.byDID[did] - if !ok { - return []*APIKey{} - } - - result := make([]*APIKey, 0, len(keyHashes)) - for _, hash := range keyHashes { - if apiKey, ok := s.keys[hash]; ok { - // Return copy without hash - keyCopy := *apiKey - keyCopy.KeyHash = "" // Don't expose hash - result = append(result, &keyCopy) - } - } - - return result -} - -// Delete removes an API key -func (s *Store) Delete(did, keyID string) error { - s.mu.Lock() - defer s.mu.Unlock() - - // Find the key by DID and ID - keyHashes, ok := s.byDID[did] - if !ok { - return fmt.Errorf("no keys found for DID: %s", did) - } - - var foundHash string - for _, hash := range keyHashes { - if apiKey, ok := s.keys[hash]; ok && apiKey.ID == keyID { - foundHash = hash - break - } - } - - if foundHash == "" { - return fmt.Errorf("key not found: %s", keyID) - } - - // Remove from keys map - delete(s.keys, foundHash) - - // Remove from byDID index - newHashes := make([]string, 0, len(keyHashes)-1) - for _, hash := range keyHashes { - if hash != foundHash { - newHashes = append(newHashes, hash) - } - } - - if len(newHashes) == 0 { - delete(s.byDID, did) - } else { - s.byDID[did] = newHashes - } - - return s.save() -} - -// UpdateLastUsed updates the last used timestamp -func (s *Store) UpdateLastUsed(keyHash string) error { - s.mu.Lock() - defer s.mu.Unlock() - - apiKey, ok := s.keys[keyHash] - if !ok { - return fmt.Errorf("key not found") - } - - apiKey.LastUsed = time.Now() - return s.save() -} - -// load reads keys from disk -func (s *Store) load() error { - data, err := os.ReadFile(s.filePath) - if err != nil { - return err - } - - var pd persistentData - if err := json.Unmarshal(data, &pd); err != nil { - return fmt.Errorf("failed to unmarshal keys: %w", err) - } - - // Rebuild in-memory structures - for _, apiKey := range pd.Keys { - s.keys[apiKey.KeyHash] = apiKey - s.byDID[apiKey.DID] = append(s.byDID[apiKey.DID], apiKey.KeyHash) - } - - return nil -} - -// save writes keys to disk -func (s *Store) save() error { - // Collect all keys - allKeys := make([]*APIKey, 0, len(s.keys)) - for _, apiKey := range s.keys { - allKeys = append(allKeys, apiKey) - } - - pd := persistentData{ - Keys: allKeys, - } - - data, err := json.MarshalIndent(pd, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal keys: %w", err) - } - - // Write atomically with temp file + rename - tmpPath := s.filePath + ".tmp" - if err := os.WriteFile(tmpPath, data, 0600); err != nil { - return fmt.Errorf("failed to write temp file: %w", err) - } - - if err := os.Rename(tmpPath, s.filePath); err != nil { - return fmt.Errorf("failed to rename temp file: %w", err) - } - - return nil -} diff --git a/pkg/appview/appview.go b/pkg/appview/appview.go index 8524c8f..2f89f25 100644 --- a/pkg/appview/appview.go +++ b/pkg/appview/appview.go @@ -63,6 +63,20 @@ func Templates() (*template.Template, error) { } return digest[:length] + "..." }, + + "firstChar": func(s string) string { + if len(s) == 0 { + return "?" + } + return string([]rune(s)[0]) + }, + + "trimPrefix": func(s, prefix string) string { + if len(s) >= len(prefix) && s[:len(prefix)] == prefix { + return s[len(prefix):] + } + return s + }, } tmpl := template.New("").Funcs(funcMap) diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index 1885a96..82c5343 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -7,6 +7,7 @@ type User struct { DID string Handle string PDSEndpoint string + Avatar string LastSeen time.Time } diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index e87bf17..f6de1de 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -168,10 +168,10 @@ func GetUserRepositories(db *sql.DB, did string) ([]Repository, error) { func GetUserByDID(db *sql.DB, did string) (*User, error) { var user User err := db.QueryRow(` - SELECT did, handle, pds_endpoint, last_seen + SELECT did, handle, pds_endpoint, avatar, last_seen FROM users WHERE did = ? - `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.LastSeen) + `, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &user.Avatar, &user.LastSeen) if err == sql.ErrNoRows { return nil, nil @@ -186,13 +186,14 @@ func GetUserByDID(db *sql.DB, did string) (*User, error) { // UpsertUser inserts or updates a user record func UpsertUser(db *sql.DB, user *User) error { _, err := db.Exec(` - INSERT INTO users (did, handle, pds_endpoint, last_seen) - VALUES (?, ?, ?, ?) + INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, pds_endpoint = excluded.pds_endpoint, + avatar = excluded.avatar, last_seen = excluded.last_seen - `, user.DID, user.Handle, user.PDSEndpoint, user.LastSeen) + `, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen) return err } diff --git a/pkg/appview/db/schema.go b/pkg/appview/db/schema.go index fcd963c..f4730bd 100644 --- a/pkg/appview/db/schema.go +++ b/pkg/appview/db/schema.go @@ -2,6 +2,7 @@ package db import ( "database/sql" + "strings" _ "github.com/mattn/go-sqlite3" ) @@ -11,6 +12,7 @@ CREATE TABLE IF NOT EXISTS users ( did TEXT PRIMARY KEY, handle TEXT NOT NULL, pds_endpoint TEXT NOT NULL, + avatar TEXT, last_seen TIMESTAMP NOT NULL, UNIQUE(handle) ); @@ -90,5 +92,82 @@ func InitDB(path string) (*sql.DB, error) { return nil, err } + // Migration: Add avatar column if it doesn't exist + _, err = db.Exec(`ALTER TABLE users ADD COLUMN avatar TEXT`) + // Ignore error if column already exists + if err != nil && !strings.Contains(err.Error(), "duplicate column") { + // Log but don't fail - column might already exist + } + + // Migration: Convert old cdn.bsky.app avatar URLs to imgs.blue + if err := migrateCDNURLs(db); err != nil { + // Log but don't fail - not critical + println("Warning: Failed to migrate CDN URLs:", err.Error()) + } + return db, nil } + +// migrateCDNURLs converts old cdn.bsky.app avatar URLs to imgs.blue format +// Old format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg +// New format: https://imgs.blue/did:plc:abc123/bafkreibxuy73... +func migrateCDNURLs(db *sql.DB) error { + // Find all users with cdn.bsky.app avatars + rows, err := db.Query(`SELECT did, avatar FROM users WHERE avatar LIKE 'https://cdn.bsky.app/%'`) + if err != nil { + return err + } + defer rows.Close() + + updates := []struct { + did string + newURL string + }{} + + for rows.Next() { + var did, oldURL string + if err := rows.Scan(&did, &oldURL); err != nil { + continue + } + + // Extract CID from old URL + // Format: https://cdn.bsky.app/img/avatar/plain/did:plc:abc123/bafkreibxuy73...@jpeg + parts := strings.Split(oldURL, "/") + if len(parts) < 7 { + continue + } + + // Get the last part which contains CID@format + cidPart := parts[len(parts)-1] + // Strip off @jpeg or @png suffix + cid := strings.Split(cidPart, "@")[0] + + // Construct new imgs.blue URL + newURL := "https://imgs.blue/" + did + "/" + cid + + updates = append(updates, struct { + did string + newURL string + }{did, newURL}) + } + + // Update all users + stmt, err := db.Prepare(`UPDATE users SET avatar = ? WHERE did = ?`) + if err != nil { + return err + } + defer stmt.Close() + + for _, update := range updates { + if _, err := stmt.Exec(update.newURL, update.did); err != nil { + // Log but continue + println("Warning: Failed to update avatar for", update.did, ":", err.Error()) + } + } + + if len(updates) > 0 { + println("Migrated", len(updates), "avatar URLs from cdn.bsky.app to imgs.blue") + } + + return nil +} diff --git a/pkg/appview/device/store.go b/pkg/appview/device/store.go new file mode 100644 index 0000000..a5d650f --- /dev/null +++ b/pkg/appview/device/store.go @@ -0,0 +1,395 @@ +package device + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// Device represents an authorized device +type Device struct { + ID string `json:"id"` // UUID + DID string `json:"did"` // Owner DID (links to OAuth session) + Handle string `json:"handle"` // Owner handle + Name string `json:"name"` // Device name (hostname) + SecretHash string `json:"secret_hash"` // bcrypt hash of device secret + IPAddress string `json:"ip_address"` // Registration IP + Location string `json:"location"` // GeoIP location (optional) + UserAgent string `json:"user_agent"` // Client info + CreatedAt time.Time `json:"created_at"` + LastUsed time.Time `json:"last_used"` +} + +// PendingAuthorization represents a device awaiting user approval +type PendingAuthorization struct { + DeviceCode string `json:"device_code"` // Long code for polling + UserCode string `json:"user_code"` // Short code shown to user + DeviceName string `json:"device_name"` // Device hostname + IPAddress string `json:"ip_address"` // Request IP + UserAgent string `json:"user_agent"` // Client user agent + ExpiresAt time.Time `json:"expires_at"` // Expiration (10 minutes) + ApprovedDID string `json:"approved_did"` // Set when approved + ApprovedAt time.Time `json:"approved_at"` // Set when approved + DeviceSecret string `json:"device_secret"` // Generated after approval +} + +// Store manages devices and pending authorizations +type Store struct { + mu sync.RWMutex + devices map[string]*Device // secretHash -> Device + byDID map[string][]string // DID -> []secretHash + pending map[string]*PendingAuthorization // deviceCode -> pending auth + pendingByUser map[string]*PendingAuthorization // userCode -> pending auth + filePath string +} + +// persistentData is saved to disk +type persistentData struct { + Devices []*Device `json:"devices"` + Pending []*PendingAuthorization `json:"pending"` +} + +// NewStore creates a new device store +func NewStore(filePath string) (*Store, error) { + s := &Store{ + devices: make(map[string]*Device), + byDID: make(map[string][]string), + pending: make(map[string]*PendingAuthorization), + pendingByUser: make(map[string]*PendingAuthorization), + filePath: filePath, + } + + // Load existing data + if err := s.load(); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("failed to load devices: %w", err) + } + + return s, nil +} + +// CreatePendingAuth creates a new pending device authorization +func (s *Store) CreatePendingAuth(deviceName, ip, userAgent string) (*PendingAuthorization, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Generate device code (long, random) + deviceCodeBytes := make([]byte, 32) + if _, err := rand.Read(deviceCodeBytes); err != nil { + return nil, fmt.Errorf("failed to generate device code: %w", err) + } + deviceCode := base64.RawURLEncoding.EncodeToString(deviceCodeBytes) + + // Generate user code (short, human-readable) + userCode := generateUserCode() + + pending := &PendingAuthorization{ + DeviceCode: deviceCode, + UserCode: userCode, + DeviceName: deviceName, + IPAddress: ip, + UserAgent: userAgent, + ExpiresAt: time.Now().Add(10 * time.Minute), + } + + s.pending[deviceCode] = pending + s.pendingByUser[userCode] = pending + + if err := s.save(); err != nil { + return nil, fmt.Errorf("failed to save pending auth: %w", err) + } + + return pending, nil +} + +// GetPendingByUserCode retrieves a pending auth by user code +func (s *Store) GetPendingByUserCode(userCode string) (*PendingAuthorization, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + pending, ok := s.pendingByUser[userCode] + if !ok || time.Now().After(pending.ExpiresAt) { + return nil, false + } + + return pending, true +} + +// GetPendingByDeviceCode retrieves a pending auth by device code +func (s *Store) GetPendingByDeviceCode(deviceCode string) (*PendingAuthorization, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + pending, ok := s.pending[deviceCode] + if !ok || time.Now().After(pending.ExpiresAt) { + return nil, false + } + + return pending, true +} + +// ApprovePending approves a pending authorization and generates device secret +func (s *Store) ApprovePending(userCode, did, handle string) (deviceSecret string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + + pending, ok := s.pendingByUser[userCode] + if !ok { + return "", fmt.Errorf("pending authorization not found") + } + + if time.Now().After(pending.ExpiresAt) { + return "", fmt.Errorf("authorization expired") + } + + if pending.ApprovedDID != "" { + return "", fmt.Errorf("already approved") + } + + // Generate device secret + secretBytes := make([]byte, 32) + if _, err := rand.Read(secretBytes); err != nil { + return "", fmt.Errorf("failed to generate device secret: %w", err) + } + deviceSecret = "atcr_device_" + base64.RawURLEncoding.EncodeToString(secretBytes) + + // Hash for storage + secretHashBytes, err := bcrypt.GenerateFromPassword([]byte(deviceSecret), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash device secret: %w", err) + } + secretHash := string(secretHashBytes) + + // Create device record + device := &Device{ + ID: uuid.New().String(), + DID: did, + Handle: handle, + Name: pending.DeviceName, + SecretHash: secretHash, + IPAddress: pending.IPAddress, + UserAgent: pending.UserAgent, + CreatedAt: time.Now(), + LastUsed: time.Time{}, // Never used yet + } + + // Store device + s.devices[secretHash] = device + s.byDID[did] = append(s.byDID[did], secretHash) + + // Mark pending as approved + pending.ApprovedDID = did + pending.ApprovedAt = time.Now() + pending.DeviceSecret = deviceSecret // Store plaintext temporarily for polling + + if err := s.save(); err != nil { + return "", fmt.Errorf("failed to save device: %w", err) + } + + return deviceSecret, nil +} + +// ValidateDeviceSecret validates a device secret and returns the device +func (s *Store) ValidateDeviceSecret(secret string) (*Device, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + // Try to match against all stored hashes + for hash, device := range s.devices { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(secret)); err == nil { + // Update last used asynchronously + go s.UpdateLastUsed(hash) + + // Return a copy + deviceCopy := *device + return &deviceCopy, nil + } + } + + return nil, fmt.Errorf("invalid device secret") +} + +// ListDevices returns all devices for a DID +func (s *Store) ListDevices(did string) []*Device { + s.mu.RLock() + defer s.mu.RUnlock() + + hashes, ok := s.byDID[did] + if !ok { + return []*Device{} + } + + result := make([]*Device, 0, len(hashes)) + for _, hash := range hashes { + if device, ok := s.devices[hash]; ok { + // Return copy without hash + deviceCopy := *device + deviceCopy.SecretHash = "" + result = append(result, &deviceCopy) + } + } + + return result +} + +// RevokeDevice removes a device +func (s *Store) RevokeDevice(did, deviceID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + hashes, ok := s.byDID[did] + if !ok { + return fmt.Errorf("no devices found for DID") + } + + var foundHash string + for _, hash := range hashes { + if device, ok := s.devices[hash]; ok && device.ID == deviceID { + foundHash = hash + break + } + } + + if foundHash == "" { + return fmt.Errorf("device not found") + } + + // Remove from devices map + delete(s.devices, foundHash) + + // Remove from byDID index + newHashes := make([]string, 0, len(hashes)-1) + for _, hash := range hashes { + if hash != foundHash { + newHashes = append(newHashes, hash) + } + } + + if len(newHashes) == 0 { + delete(s.byDID, did) + } else { + s.byDID[did] = newHashes + } + + return s.save() +} + +// UpdateLastUsed updates the last used timestamp +func (s *Store) UpdateLastUsed(secretHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + device, ok := s.devices[secretHash] + if !ok { + return fmt.Errorf("device not found") + } + + device.LastUsed = time.Now() + return s.save() +} + +// CleanupExpired removes expired pending authorizations +func (s *Store) CleanupExpired() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + modified := false + + for deviceCode, pending := range s.pending { + if now.After(pending.ExpiresAt) { + delete(s.pending, deviceCode) + delete(s.pendingByUser, pending.UserCode) + modified = true + } + } + + if modified { + s.save() + } +} + +// load reads data from disk +func (s *Store) load() error { + data, err := os.ReadFile(s.filePath) + if err != nil { + return err + } + + var pd persistentData + if err := json.Unmarshal(data, &pd); err != nil { + return fmt.Errorf("failed to unmarshal devices: %w", err) + } + + // Rebuild in-memory structures + for _, device := range pd.Devices { + s.devices[device.SecretHash] = device + s.byDID[device.DID] = append(s.byDID[device.DID], device.SecretHash) + } + + for _, pending := range pd.Pending { + // Only load non-expired + if time.Now().Before(pending.ExpiresAt) { + s.pending[pending.DeviceCode] = pending + s.pendingByUser[pending.UserCode] = pending + } + } + + return nil +} + +// save writes data to disk +func (s *Store) save() error { + // Collect all devices + allDevices := make([]*Device, 0, len(s.devices)) + for _, device := range s.devices { + allDevices = append(allDevices, device) + } + + // Collect all pending + allPending := make([]*PendingAuthorization, 0, len(s.pending)) + for _, pending := range s.pending { + allPending = append(allPending, pending) + } + + pd := persistentData{ + Devices: allDevices, + Pending: allPending, + } + + data, err := json.MarshalIndent(pd, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal devices: %w", err) + } + + // Write atomically + tmpPath := s.filePath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0600); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + + if err := os.Rename(tmpPath, s.filePath); err != nil { + return fmt.Errorf("failed to rename temp file: %w", err) + } + + return nil +} + +// generateUserCode creates a short, human-readable code +// Format: XXXX-XXXX (e.g., "WDJB-MJHT") +// Character set: A-Z excluding ambiguous chars (0, O, I, 1, L) +func generateUserCode() string { + chars := "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + code := make([]byte, 8) + rand.Read(code) + for i := range code { + code[i] = chars[int(code[i])%len(chars)] + } + return string(code[:4]) + "-" + string(code[4:]) +} diff --git a/pkg/appview/handlers/apikeys.go b/pkg/appview/handlers/apikeys.go deleted file mode 100644 index fb69265..0000000 --- a/pkg/appview/handlers/apikeys.go +++ /dev/null @@ -1,91 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "net/http" - - "atcr.io/pkg/appview/apikey" - "atcr.io/pkg/appview/middleware" - "github.com/gorilla/mux" -) - -// GenerateAPIKeyHandler handles POST /api/keys -type GenerateAPIKeyHandler struct { - Store *apikey.Store -} - -func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - name := r.FormValue("name") - if name == "" { - name = "Unnamed Key" - } - - key, keyID, err := h.Store.Generate(user.DID, user.Handle, name) - if err != nil { - fmt.Printf("ERROR [apikeys]: Failed to generate key for DID=%s: %v\n", user.DID, err) - http.Error(w, "Failed to generate key", http.StatusInternalServerError) - return - } - - fmt.Printf("INFO [apikeys]: Generated API key for DID=%s, handle=%s, name=%s, keyID=%s\n", - user.DID, user.Handle, name, keyID) - - // Return key (shown once!) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "id": keyID, - "key": key, - }) -} - -// ListAPIKeysHandler handles GET /api/keys -type ListAPIKeysHandler struct { - Store *apikey.Store -} - -func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - keys := h.Store.List(user.DID) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(keys) -} - -// DeleteAPIKeyHandler handles DELETE /api/keys/{id} -type DeleteAPIKeyHandler struct { - Store *apikey.Store -} - -func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - vars := mux.Vars(r) - keyID := vars["id"] - - if err := h.Store.Delete(user.DID, keyID); err != nil { - fmt.Printf("ERROR [apikeys]: Failed to delete key for DID=%s, keyID=%s: %v\n", - user.DID, keyID, err) - http.Error(w, "Failed to delete key", http.StatusInternalServerError) - return - } - - fmt.Printf("INFO [apikeys]: Deleted API key for DID=%s, keyID=%s\n", user.DID, keyID) - - w.WriteHeader(http.StatusNoContent) -} diff --git a/pkg/appview/handlers/auth.go b/pkg/appview/handlers/auth.go index 1126b90..7a89695 100644 --- a/pkg/appview/handlers/auth.go +++ b/pkg/appview/handlers/auth.go @@ -1,8 +1,14 @@ package handlers import ( + "fmt" "html/template" "net/http" + "time" + + "atcr.io/pkg/auth/oauth" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" ) // LoginHandler shows the OAuth login form @@ -31,7 +37,16 @@ func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // LoginSubmitHandler processes the login form submission -type LoginSubmitHandler struct{} +type LoginSubmitHandler struct { + Refresher *oauth.Refresher + Directory identity.Directory + SessionStore UISessionStore +} + +// UISessionStore is the interface for UI session management +type UISessionStore interface { + CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) +} func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -41,12 +56,71 @@ func (h *LoginSubmitHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { handle := r.FormValue("handle") returnTo := r.FormValue("return_to") + if returnTo == "" { + returnTo = "/" + } if handle == "" { http.Redirect(w, r, "/auth/oauth/login?return_to="+returnTo+"&error=handle_required", http.StatusFound) return } + // Attempt silent login first + if h.Refresher != nil && h.Directory != nil && h.SessionStore != nil { + // Parse handle + handleSyntax, err := syntax.ParseHandle(handle) + if err == nil { + // Resolve handle to identity (DID + PDS endpoint) + ident, err := h.Directory.LookupHandle(r.Context(), handleSyntax) + if err == nil { + did := ident.DID.String() + + // Try to get existing OAuth session + _, err := h.Refresher.GetSession(r.Context(), did) + if err == nil { + // Found valid OAuth session! Create UI session silently + fmt.Printf("DEBUG [auth]: Silent login successful for %s (DID: %s)\n", handle, did) + + // Get PDS endpoint from identity + pdsEndpoint := ident.PDSEndpoint() + + // Get OAuth sessionID from refresher + sessionID := h.Refresher.GetSessionID(did) + + uiSessionID, err := h.SessionStore.CreateWithOAuth(did, handle, pdsEndpoint, sessionID, 30*24*time.Hour) + if err == nil { + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "atcr_session", + Value: uiSessionID, + Path: "/", + MaxAge: 30 * 86400, // 30 days + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + + // Redirect to return URL + fmt.Printf("DEBUG [auth]: Silent login complete, redirecting to %s\n", returnTo) + http.Redirect(w, r, returnTo, http.StatusFound) + return + } + + fmt.Printf("WARNING [auth]: Failed to create UI session during silent login: %v\n", err) + } else { + fmt.Printf("DEBUG [auth]: No valid OAuth session found for %s: %v\n", handle, err) + } + } else { + fmt.Printf("DEBUG [auth]: Failed to resolve handle %s: %v\n", handle, err) + } + } else { + fmt.Printf("DEBUG [auth]: Failed to parse handle %s: %v\n", handle, err) + } + } + + // Silent login failed or not configured - proceed with full OAuth flow + fmt.Printf("DEBUG [auth]: Proceeding with full OAuth flow for %s\n", handle) + // Store return_to in cookie so callback can use it http.SetCookie(w, &http.Cookie{ Name: "oauth_return_to", diff --git a/pkg/appview/handlers/device.go b/pkg/appview/handlers/device.go new file mode 100644 index 0000000..a659bcf --- /dev/null +++ b/pkg/appview/handlers/device.go @@ -0,0 +1,531 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "atcr.io/pkg/appview/device" + "atcr.io/pkg/appview/session" +) + +// DeviceCodeRequest is the request to start device authorization +type DeviceCodeRequest struct { + DeviceName string `json:"device_name"` +} + +// DeviceCodeResponse is the response with user and device codes +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// DeviceCodeHandler handles POST /auth/device/code +type DeviceCodeHandler struct { + Store *device.Store + AppViewBaseURL string // e.g., "http://localhost:5000" +} + +func (h *DeviceCodeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req DeviceCodeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Default device name if not provided + if req.DeviceName == "" { + req.DeviceName = "Unknown Device" + } + + // Get client IP + ip := getClientIP(r) + + // Get user agent + userAgent := r.UserAgent() + + // Create pending authorization + pending, err := h.Store.CreatePendingAuth(req.DeviceName, ip, userAgent) + if err != nil { + http.Error(w, "failed to create authorization", http.StatusInternalServerError) + return + } + + // Return device code info + resp := DeviceCodeResponse{ + DeviceCode: pending.DeviceCode, + UserCode: pending.UserCode, + VerificationURI: h.AppViewBaseURL + "/device", + ExpiresIn: 600, // 10 minutes + Interval: 5, // Poll every 5 seconds + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// DeviceTokenRequest is the request to poll for device authorization +type DeviceTokenRequest struct { + DeviceCode string `json:"device_code"` +} + +// DeviceTokenResponse is the response with device secret or error +type DeviceTokenResponse struct { + DeviceSecret string `json:"device_secret,omitempty"` + Handle string `json:"handle,omitempty"` + DID string `json:"did,omitempty"` + Error string `json:"error,omitempty"` +} + +// DeviceTokenHandler handles POST /auth/device/token +type DeviceTokenHandler struct { + Store *device.Store +} + +func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req DeviceTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + // Get pending authorization + pending, ok := h.Store.GetPendingByDeviceCode(req.DeviceCode) + if !ok { + resp := DeviceTokenResponse{ + Error: "expired_token", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + // Check if approved + if pending.ApprovedDID == "" { + // Still pending + resp := DeviceTokenResponse{ + Error: "authorization_pending", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + // Approved! Get device from store to find handle + devices := h.Store.ListDevices(pending.ApprovedDID) + var handle string + for _, d := range devices { + if d.DID == pending.ApprovedDID { + handle = d.Handle + break + } + } + + // Return device secret + resp := DeviceTokenResponse{ + DeviceSecret: pending.DeviceSecret, + Handle: handle, + DID: pending.ApprovedDID, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// DeviceApprovalPageHandler handles GET /device +type DeviceApprovalPageHandler struct { + Store *device.Store + SessionStore *session.Store +} + +func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check if user is logged in + sessionID, ok := session.GetSessionID(r) + if !ok { + // Not logged in - redirect to login with return URL + http.SetCookie(w, &http.Cookie{ + Name: "oauth_return_to", + Value: r.URL.RequestURI(), + Path: "/", + MaxAge: 600, // 10 minutes + HttpOnly: true, + }) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + sess, ok := h.SessionStore.Get(sessionID) + if !ok { + // Invalid session + http.SetCookie(w, &http.Cookie{ + Name: "oauth_return_to", + Value: r.URL.RequestURI(), + Path: "/", + MaxAge: 600, + HttpOnly: true, + }) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + // Get user code from query + userCode := r.URL.Query().Get("user_code") + if userCode == "" { + http.Error(w, "user_code required", http.StatusBadRequest) + return + } + + // Get pending authorization + pending, ok := h.Store.GetPendingByUserCode(userCode) + if !ok { + h.renderError(w, "Invalid or expired authorization code") + return + } + + // Check if already approved + if pending.ApprovedDID != "" { + h.renderSuccess(w, pending.DeviceName) + return + } + + // Render approval page + h.renderApprovalPage(w, sess.Handle, pending) +} + +// DeviceApproveRequest is the request to approve a device +type DeviceApproveRequest struct { + UserCode string `json:"user_code"` + Approve bool `json:"approve"` +} + +// DeviceApproveHandler handles POST /device/approve +type DeviceApproveHandler struct { + Store *device.Store + SessionStore *session.Store +} + +func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check session + sessionID, ok := session.GetSessionID(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sess, ok := h.SessionStore.Get(sessionID) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var req DeviceApproveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if !req.Approve { + // User denied + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "denied"}) + return + } + + // Approve the device + _, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle) + if err != nil { + http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) +} + +// ListDevicesHandler handles GET /api/devices +type ListDevicesHandler struct { + Store *device.Store + SessionStore *session.Store +} + +func (h *ListDevicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check session + sessionID, ok := session.GetSessionID(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sess, ok := h.SessionStore.Get(sessionID) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Get devices for this user + devices := h.Store.ListDevices(sess.DID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(devices) +} + +// RevokeDeviceHandler handles DELETE /api/devices/{id} +type RevokeDeviceHandler struct { + Store *device.Store + SessionStore *session.Store +} + +func (h *RevokeDeviceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Check session + sessionID, ok := session.GetSessionID(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sess, ok := h.SessionStore.Get(sessionID) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Get device ID from URL + vars := mux.Vars(r) + deviceID := vars["id"] + if deviceID == "" { + http.Error(w, "device ID required", http.StatusBadRequest) + return + } + + // Revoke device + if err := h.Store.RevokeDevice(sess.DID, deviceID); err != nil { + http.Error(w, fmt.Sprintf("failed to revoke: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// Helper functions + +func (h *DeviceApprovalPageHandler) renderApprovalPage(w http.ResponseWriter, handle string, pending *device.PendingAuthorization) { + tmpl := template.Must(template.New("approval").Parse(deviceApprovalTemplate)) + data := struct { + Handle string + DeviceName string + UserCode string + IPAddress string + }{ + Handle: handle, + DeviceName: pending.DeviceName, + UserCode: pending.UserCode, + IPAddress: pending.IPAddress, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl.Execute(w, data) +} + +func (h *DeviceApprovalPageHandler) renderSuccess(w http.ResponseWriter, deviceName string) { + tmpl := template.Must(template.New("success").Parse(deviceSuccessTemplate)) + data := struct { + DeviceName string + }{ + DeviceName: deviceName, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl.Execute(w, data) +} + +func (h *DeviceApprovalPageHandler) renderError(w http.ResponseWriter, message string) { + tmpl := template.Must(template.New("error").Parse(deviceErrorTemplate)) + data := struct { + Message string + }{ + Message: message, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + tmpl.Execute(w, data) +} + +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + + // Check X-Real-IP header + xri := r.Header.Get("X-Real-IP") + if xri != "" { + return xri + } + + // Fall back to RemoteAddr + parts := strings.Split(r.RemoteAddr, ":") + if len(parts) > 0 { + return parts[0] + } + + return r.RemoteAddr +} + +// HTML templates + +const deviceApprovalTemplate = ` + + + + Authorize Device - ATCR + + + +
+

Authorize Device

+

User: {{.Handle}}

+ +
{{.UserCode}}
+ +
+
+
Device Name:
+
{{.DeviceName}}
+
IP Address:
+
{{.IPAddress}}
+
+
+ +

Do you want to authorize this device?

+

This device will be able to push and pull container images to your registry.

+ +
+ + +
+
+ + + + +` + +const deviceSuccessTemplate = ` + + + + Device Authorized - ATCR + + + +
+

✓ Device Authorized!

+

Device {{.DeviceName}} has been successfully authorized.

+

You can now close this window and return to your terminal.

+

View your authorized devices

+
+ + +` + +const deviceErrorTemplate = ` + + + + Authorization Error - ATCR + + + +
+

✗ Authorization Error

+

{{.Message}}

+

Return to home

+
+ + +` diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index a32ad15..836e19c 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -14,8 +14,9 @@ import ( // SettingsHandler handles the settings page type SettingsHandler struct { - Templates *template.Template - Refresher *oauth.Refresher + Templates *template.Template + Refresher *oauth.Refresher + RegistryURL string } func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -60,10 +61,12 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } SessionExpiry time.Time Query string + RegistryURL string }{ User: user, SessionExpiry: time.Now().Add(24 * time.Hour), // TODO: Get from actual session Query: r.URL.Query().Get("q"), + RegistryURL: h.RegistryURL, } data.Profile.Handle = user.Handle diff --git a/pkg/appview/jetstream/backfill.go b/pkg/appview/jetstream/backfill.go index 29b4b76..a6795d3 100644 --- a/pkg/appview/jetstream/backfill.go +++ b/pkg/appview/jetstream/backfill.go @@ -363,11 +363,24 @@ func (b *BackfillWorker) ensureUser(ctx context.Context, did string) error { pdsEndpoint = "https://bsky.social" } + // Fetch user's Bluesky profile (including avatar) + // Use public Bluesky AppView API (doesn't require auth for public profiles) + avatar := "" + publicClient := atproto.NewClient("https://public.api.bsky.app", "", "") + profile, err := publicClient.GetActorProfile(ctx, resolvedDID) + if err != nil { + fmt.Printf("WARNING [backfill]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err) + // Continue without avatar + } else { + avatar = profile.Avatar + } + // Upsert to database user := &db.User{ DID: resolvedDID, Handle: handle, PDSEndpoint: pdsEndpoint, + Avatar: avatar, LastSeen: time.Now(), } diff --git a/pkg/appview/jetstream/worker.go b/pkg/appview/jetstream/worker.go index c897776..d4d56b4 100644 --- a/pkg/appview/jetstream/worker.go +++ b/pkg/appview/jetstream/worker.go @@ -36,6 +36,7 @@ type Worker struct { userCache *UserCache directory identity.Directory eventCallback EventCallback + connStartTime time.Time // Track when connection started for debugging } // NewWorker creates a new Jetstream worker @@ -93,6 +94,9 @@ func (w *Worker) Start(ctx context.Context) error { } defer conn.Close() + // Track connection start time for debugging + w.connStartTime = time.Now() + // Create zstd decoder for decompressing messages decoder, err := zstd.NewReader(nil) if err != nil { @@ -122,6 +126,16 @@ func (w *Worker) Start(ctx context.Context) error { default: _, message, err := conn.ReadMessage() if err != nil { + // Calculate connection duration and idle time for debugging + connDuration := time.Since(w.connStartTime) + timeSinceLastEvent := time.Since(lastHeartbeat) + + // Log detailed context about the failure + fmt.Printf("Jetstream: Connection closed after %s\n", connDuration) + fmt.Printf(" - Events in last 30s: %d\n", eventCount) + fmt.Printf(" - Time since last event: %s\n", timeSinceLastEvent) + fmt.Printf(" - Error: %v\n", err) + return fmt.Errorf("failed to read message: %w", err) } @@ -241,11 +255,24 @@ func (w *Worker) ensureUser(ctx context.Context, did string) error { pdsEndpoint = "https://bsky.social" } + // Fetch user's Bluesky profile (including avatar) + // Use public Bluesky AppView API (doesn't require auth for public profiles) + avatar := "" + publicClient := atproto.NewClient("https://public.api.bsky.app", "", "") + profile, err := publicClient.GetActorProfile(ctx, resolvedDID) + if err != nil { + fmt.Printf("WARNING [worker]: Failed to fetch profile for DID %s: %v\n", resolvedDID, err) + // Continue without avatar + } else { + avatar = profile.Avatar + } + // Cache the user user := &db.User{ DID: resolvedDID, Handle: handle, PDSEndpoint: pdsEndpoint, + Avatar: avatar, LastSeen: time.Now(), } w.userCache.cache[did] = user diff --git a/pkg/appview/middleware/auth.go b/pkg/appview/middleware/auth.go index f886d2d..0c008a4 100644 --- a/pkg/appview/middleware/auth.go +++ b/pkg/appview/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "database/sql" "net/http" "atcr.io/pkg/appview/db" @@ -13,7 +14,7 @@ type contextKey string const userKey contextKey = "user" // RequireAuth is middleware that requires authentication -func RequireAuth(store *session.Store) func(http.Handler) http.Handler { +func RequireAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionID, ok := session.GetSessionID(r) @@ -28,10 +29,15 @@ func RequireAuth(store *session.Store) func(http.Handler) http.Handler { return } - user := &db.User{ - DID: sess.DID, - Handle: sess.Handle, - PDSEndpoint: sess.PDSEndpoint, + // Look up full user from database to get avatar + user, err := db.GetUserByDID(database, sess.DID) + if err != nil || user == nil { + // Fallback to session data if DB lookup fails + user = &db.User{ + DID: sess.DID, + Handle: sess.Handle, + PDSEndpoint: sess.PDSEndpoint, + } } ctx := context.WithValue(r.Context(), userKey, user) @@ -41,16 +47,21 @@ func RequireAuth(store *session.Store) func(http.Handler) http.Handler { } // OptionalAuth is middleware that optionally includes user if authenticated -func OptionalAuth(store *session.Store) func(http.Handler) http.Handler { +func OptionalAuth(store *session.Store, database *sql.DB) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionID, ok := session.GetSessionID(r) if ok { if sess, ok := store.Get(sessionID); ok { - user := &db.User{ - DID: sess.DID, - Handle: sess.Handle, - PDSEndpoint: sess.PDSEndpoint, + // Look up full user from database to get avatar + user, err := db.GetUserByDID(database, sess.DID) + if err != nil || user == nil { + // Fallback to session data if DB lookup fails + user = &db.User{ + DID: sess.DID, + Handle: sess.Handle, + PDSEndpoint: sess.PDSEndpoint, + } } ctx := context.WithValue(r.Context(), userKey, user) r = r.WithContext(ctx) diff --git a/pkg/appview/session/session.go b/pkg/appview/session/session.go index 1b5ac3b..e330d8a 100644 --- a/pkg/appview/session/session.go +++ b/pkg/appview/session/session.go @@ -13,11 +13,12 @@ import ( // Session represents a user session type Session struct { - ID string - DID string - Handle string - PDSEndpoint string - ExpiresAt time.Time + ID string + DID string + Handle string + PDSEndpoint string + OAuthSessionID string // Store OAuth sessionID for resuming + ExpiresAt time.Time } // Store manages user sessions @@ -89,6 +90,11 @@ func (s *Store) save() error { // Create creates a new session and returns the session ID func (s *Store) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) { + return s.CreateWithOAuth(did, handle, pdsEndpoint, "", duration) +} + +// CreateWithOAuth creates a new session with OAuth sessionID and returns the session ID +func (s *Store) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) { s.mu.Lock() defer s.mu.Unlock() @@ -99,11 +105,12 @@ func (s *Store) Create(did, handle, pdsEndpoint string, duration time.Duration) } sess := &Session{ - ID: base64.URLEncoding.EncodeToString(b), - DID: did, - Handle: handle, - PDSEndpoint: pdsEndpoint, - ExpiresAt: time.Now().Add(duration), + ID: base64.URLEncoding.EncodeToString(b), + DID: did, + Handle: handle, + PDSEndpoint: pdsEndpoint, + OAuthSessionID: oauthSessionID, + ExpiresAt: time.Now().Add(duration), } s.sessions[sess.ID] = sess diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index 1a7ff0d..5c2e2b5 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -32,7 +32,7 @@ body { /* Navigation */ .navbar { background: var(--fg); - color: white; + color:var(--bg); padding: 1rem 2rem; display: flex; justify-content: space-between; @@ -41,7 +41,7 @@ body { } .nav-brand a { - color: white; + color:var(--bg); text-decoration: none; font-size: 1.5rem; font-weight: bold; @@ -68,29 +68,121 @@ body { } .nav-links a { - color: white; + color:var(--fg); text-decoration: none; padding: 0.5rem 1rem; } .nav-links a:hover { - background: rgba(255, 255, 255, 0.1); + background:var(--secondary); border-radius: 4px; } -.user-handle { - color: #aaa; +/* User dropdown */ +.user-dropdown { + position: relative; } -.settings-icon { - font-size: 1.2rem; +.user-menu-btn { + display: flex; + align-items: center; + gap: 0.5rem; + background: transparent; + color:var(--bg); + border: none; + padding: 0.5rem; + cursor: pointer; + border-radius: 4px; + transition: background 0.2s; +} + +.user-menu-btn:hover { + background:var(--secondary); +} + +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} + +.user-avatar-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + text-transform: uppercase; +} + +.user-handle { + color: white; + font-size: 0.95rem; +} + +.dropdown-arrow { + transition: transform 0.2s; +} + +.user-menu-btn[aria-expanded="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background:var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + overflow: hidden; + z-index: 1000; +} + +.dropdown-menu[hidden] { + display: none; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.75rem 1rem; + text-align: left; + color: var(--fg); + text-decoration: none; + border: none; + background:var(--bg); + cursor: pointer; + transition: background 0.2s; + font-size: 0.95rem; +} + +.dropdown-item:hover { + background: var(--hover-bg); +} + +.dropdown-divider { + margin: 0; + border: none; + border-top: 1px solid var(--border); +} + +.logout-btn { + color: var(--danger); + font-weight: 500; } /* Buttons */ button, .btn, .btn-primary, .btn-secondary { padding: 0.5rem 1rem; background: var(--primary); - color: white; + color:var(--bg); border: none; border-radius: 4px; cursor: pointer; @@ -104,13 +196,18 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { opacity: 0.9; } +/* Override nav-links color for primary button */ +.nav-links .btn-primary { + color: var(--bg); +} + .btn-secondary { background: var(--secondary); } .btn-link { background: transparent; - color: white; + color:var(--bg); text-decoration: underline; } @@ -132,7 +229,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { border-radius: 8px; padding: 1rem; margin-bottom: 1rem; - background: white; + background:var(--bg); box-shadow: 0 1px 3px rgba(0,0,0,0.05); } @@ -286,7 +383,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .settings-section { - background: white; + background:var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; @@ -468,7 +565,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .login-form { - background: white; + background:var(--bg); padding: 2rem; border-radius: 8px; border: 1px solid var(--border); diff --git a/pkg/appview/static/js/app.js b/pkg/appview/static/js/app.js index 3f3a65b..d5ff1d1 100644 --- a/pkg/appview/static/js/app.js +++ b/pkg/appview/static/js/app.js @@ -72,3 +72,47 @@ function toggleRepo(name) { btn.textContent = '▼'; } } + +// User dropdown menu +document.addEventListener('DOMContentLoaded', () => { + const menuBtn = document.getElementById('user-menu-btn'); + const dropdownMenu = document.getElementById('user-dropdown-menu'); + + if (menuBtn && dropdownMenu) { + // Toggle dropdown on button click + menuBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isExpanded = menuBtn.getAttribute('aria-expanded') === 'true'; + + if (isExpanded) { + closeDropdown(); + } else { + openDropdown(); + } + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!menuBtn.contains(e.target) && !dropdownMenu.contains(e.target)) { + closeDropdown(); + } + }); + + // Close dropdown on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeDropdown(); + } + }); + + function openDropdown() { + menuBtn.setAttribute('aria-expanded', 'true'); + dropdownMenu.removeAttribute('hidden'); + } + + function closeDropdown() { + menuBtn.setAttribute('aria-expanded', 'false'); + dropdownMenu.setAttribute('hidden', ''); + } + } +}); diff --git a/pkg/appview/templates/components/nav.html b/pkg/appview/templates/components/nav.html index 6bf019b..e5056a7 100644 --- a/pkg/appview/templates/components/nav.html +++ b/pkg/appview/templates/components/nav.html @@ -12,12 +12,27 @@