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.

cleanup more auth

+2515 -909
+65 -60
cmd/credential-helper/main.go
··· 5 5 "fmt" 6 6 "os" 7 7 "path/filepath" 8 + "strings" 8 9 9 10 "atcr.io/pkg/auth/oauth" 10 11 ) ··· 14 15 defaultAppViewURL = "http://127.0.0.1:5000" 15 16 ) 16 17 17 - // SessionStore represents the stored session token 18 - type SessionStore struct { 19 - SessionToken string `json:"session_token"` 20 - Handle string `json:"handle"` 21 - AppViewURL string `json:"appview_url"` 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"` 22 23 } 23 24 24 25 // Docker credential helper protocol ··· 68 69 os.Exit(1) 69 70 } 70 71 71 - // Load session from storage 72 - sessionPath := getSessionPath() 73 - session, err := loadSession(sessionPath) 72 + // Load credentials from storage 73 + credsPath := getCredentialsPath() 74 + storedCreds, err := loadCredentials(credsPath) 74 75 if err != nil { 75 - fmt.Fprintf(os.Stderr, "Error loading session: %v\n", err) 76 + fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err) 76 77 fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n") 77 78 os.Exit(1) 78 79 } 79 80 80 - // Return session token as credentials 81 - // Docker will call /auth/token with this, and the token handler 82 - // will validate the session token and issue a registry JWT 81 + // 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 83 84 creds := Credentials{ 84 85 ServerURL: serverURL, 85 - Username: "oauth2", // Signals token-based auth to Docker 86 - Secret: session.SessionToken, // Return session token directly 86 + Username: storedCreds.Handle, // Use handle as username 87 + Secret: storedCreds.APIKey, // API key as password 87 88 } 88 89 89 90 if err := json.NewEncoder(os.Stdout).Encode(creds); err != nil { ··· 114 115 os.Exit(1) 115 116 } 116 117 117 - // Remove session file 118 - sessionPath := getSessionPath() 119 - if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) { 120 - fmt.Fprintf(os.Stderr, "Error removing session: %v\n", err) 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) 121 122 os.Exit(1) 122 123 } 123 124 } 124 125 125 - // handleConfigure runs the OAuth flow to get initial credentials 126 + // handleConfigure prompts for API key and saves credentials 126 127 func handleConfigure(handle string) { 127 128 fmt.Println("ATCR Credential Helper Configuration") 128 129 fmt.Println("=====================================") 129 130 fmt.Println() 131 + fmt.Println("You need an API key from the ATCR web UI.") 132 + fmt.Println() 130 133 131 134 // Get AppView URL from environment or use default 132 135 appViewURL := os.Getenv("ATCR_APPVIEW_URL") 133 136 if appViewURL == "" { 134 137 appViewURL = defaultAppViewURL 135 138 } 136 - fmt.Printf("AppView URL: %s\n\n", appViewURL) 139 + 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() 145 + 146 + if err := oauth.OpenBrowser(settingsURL); err != nil { 147 + fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL) 148 + } 137 149 138 - // Ask for handle if not provided as argument 150 + // Prompt for credentials 139 151 if handle == "" { 140 152 fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") 141 153 if _, err := fmt.Scanln(&handle); err != nil { ··· 146 158 fmt.Printf("Using handle: %s\n", handle) 147 159 } 148 160 149 - // Open browser to AppView OAuth authorization 150 - authURL := fmt.Sprintf("%s/auth/oauth/authorize?handle=%s", appViewURL, handle) 151 - fmt.Printf("\nOpening browser to: %s\n", authURL) 152 - fmt.Println("Please complete the authorization in your browser.") 153 - fmt.Println("After authorization, you will receive a session token.") 154 - fmt.Println() 155 - 156 - if err := oauth.OpenBrowser(authURL); err != nil { 157 - fmt.Printf("Failed to open browser automatically.\nPlease open this URL manually:\n%s\n\n", authURL) 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) 158 166 } 159 167 160 - // Prompt user to paste session token 161 - fmt.Print("Enter the session token from the browser: ") 162 - var sessionToken string 163 - if _, err := fmt.Scanln(&sessionToken); err != nil { 164 - fmt.Fprintf(os.Stderr, "Error reading session token: %v\n", err) 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") 165 171 os.Exit(1) 166 172 } 167 173 168 - // Create session store 169 - session := &SessionStore{ 170 - SessionToken: sessionToken, 171 - Handle: handle, 172 - AppViewURL: appViewURL, 174 + // Save credentials 175 + creds := &CredentialStore{ 176 + Handle: handle, 177 + APIKey: apiKey, 178 + AppViewURL: appViewURL, 173 179 } 174 180 175 - // Save session 176 - sessionPath := getSessionPath() 177 - if err := saveSession(sessionPath, session); err != nil { 178 - fmt.Fprintf(os.Stderr, "Error saving session: %v\n", err) 181 + if err := saveCredentials(getCredentialsPath(), creds); err != nil { 182 + fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err) 179 183 os.Exit(1) 180 184 } 181 185 182 - fmt.Println("\n✓ Configuration complete!") 186 + fmt.Println() 187 + fmt.Println("✓ Configuration complete!") 183 188 fmt.Println("You can now use docker push/pull with atcr.io") 184 189 } 185 190 186 - // getSessionPath returns the path to the session file 187 - func getSessionPath() string { 191 + // getCredentialsPath returns the path to the credentials file 192 + func getCredentialsPath() string { 188 193 homeDir, err := os.UserHomeDir() 189 194 if err != nil { 190 195 fmt.Fprintf(os.Stderr, "Error getting home directory: %v\n", err) ··· 197 202 os.Exit(1) 198 203 } 199 204 200 - return filepath.Join(atcrDir, "session.json") 205 + return filepath.Join(atcrDir, "credentials.json") 201 206 } 202 207 203 - // loadSession loads the session from disk 204 - func loadSession(path string) (*SessionStore, error) { 208 + // loadCredentials loads the credentials from disk 209 + func loadCredentials(path string) (*CredentialStore, error) { 205 210 data, err := os.ReadFile(path) 206 211 if err != nil { 207 - return nil, fmt.Errorf("failed to read session file: %w", err) 212 + return nil, fmt.Errorf("failed to read credentials file: %w", err) 208 213 } 209 214 210 - var session SessionStore 211 - if err := json.Unmarshal(data, &session); err != nil { 212 - return nil, fmt.Errorf("failed to parse session file: %w", err) 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) 213 218 } 214 219 215 - return &session, nil 220 + return &creds, nil 216 221 } 217 222 218 - // saveSession saves the session to disk 219 - func saveSession(path string, session *SessionStore) error { 220 - data, err := json.MarshalIndent(session, "", " ") 223 + // saveCredentials saves the credentials to disk 224 + func saveCredentials(path string, creds *CredentialStore) error { 225 + data, err := json.MarshalIndent(creds, "", " ") 221 226 if err != nil { 222 - return fmt.Errorf("failed to marshal session: %w", err) 227 + return fmt.Errorf("failed to marshal credentials: %w", err) 223 228 } 224 229 225 230 if err := os.WriteFile(path, data, 0600); err != nil { 226 - return fmt.Errorf("failed to write session file: %w", err) 231 + return fmt.Errorf("failed to write credentials file: %w", err) 227 232 } 228 233 229 234 return nil
+35 -16
cmd/hold/main.go
··· 19 19 "atcr.io/pkg/atproto" 20 20 "atcr.io/pkg/auth/oauth" 21 21 indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 22 + "github.com/bluesky-social/indigo/atproto/identity" 23 + "github.com/bluesky-social/indigo/atproto/syntax" 22 24 23 25 // Import storage drivers 24 26 _ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem" ··· 437 439 438 440 ctx := context.Background() 439 441 440 - // Resolve owner's PDS endpoint 441 - resolver := atproto.NewResolver() 442 - pdsEndpoint, err := resolver.ResolvePDS(ctx, ownerDID) 442 + // Resolve owner's PDS endpoint using indigo 443 + directory := identity.DefaultDirectory() 444 + ownerDIDParsed, err := syntax.ParseDID(ownerDID) 445 + if err != nil { 446 + return false, fmt.Errorf("invalid owner DID: %w", err) 447 + } 448 + 449 + ident, err := directory.LookupDID(ctx, ownerDIDParsed) 443 450 if err != nil { 444 451 return false, fmt.Errorf("failed to resolve owner PDS: %w", err) 452 + } 453 + 454 + pdsEndpoint := ident.PDSEndpoint() 455 + if pdsEndpoint == "" { 456 + return false, fmt.Errorf("no PDS endpoint found for owner") 445 457 } 446 458 447 459 // Create unauthenticated client to read public records ··· 827 839 828 840 log.Printf("Checking registration status for DID: %s", reg.OwnerDID) 829 841 830 - // Resolve DID to PDS endpoint 831 - resolver := atproto.NewResolver() 832 - pdsEndpoint, err := resolver.ResolvePDS(ctx, reg.OwnerDID) 842 + // Resolve DID to PDS endpoint using indigo 843 + directory := identity.DefaultDirectory() 844 + didParsed, err := syntax.ParseDID(reg.OwnerDID) 845 + if err != nil { 846 + return fmt.Errorf("invalid owner DID: %w", err) 847 + } 848 + 849 + ident, err := directory.LookupDID(ctx, didParsed) 833 850 if err != nil { 834 851 return fmt.Errorf("failed to resolve PDS for DID: %w", err) 852 + } 853 + 854 + pdsEndpoint := ident.PDSEndpoint() 855 + if pdsEndpoint == "" { 856 + return fmt.Errorf("no PDS endpoint found for DID") 835 857 } 836 858 837 859 log.Printf("PDS endpoint: %s", pdsEndpoint) ··· 850 872 // Not registered, need to do OAuth 851 873 log.Printf("Hold not registered, starting OAuth flow...") 852 874 853 - // Get handle from DID document 854 - handle, err := resolver.ResolveHandleFromDID(ctx, reg.OwnerDID) 855 - if err != nil { 856 - return fmt.Errorf("failed to get handle from DID: %w", err) 875 + // Get handle from DID document (already resolved above) 876 + handle := ident.Handle.String() 877 + if handle == "" || handle == "handle.invalid" { 878 + return fmt.Errorf("no valid handle found for DID") 857 879 } 858 880 859 881 log.Printf("Resolved handle: %s", handle) ··· 932 954 log.Printf("DID: %s", did) 933 955 log.Printf("PDS: %s", pdsEndpoint) 934 956 935 - // Extract access token and HTTP client from session 936 - accessToken, _ := result.Session.GetHostAccessData() 937 - httpClient := result.Session.APIClient().Client 938 - 939 - // Create ATProto client with indigo's DPoP-configured HTTP client 940 - client := atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient) 957 + // Create ATProto client with indigo's API client (handles DPoP automatically) 958 + apiClient := result.Session.APIClient() 959 + client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) 941 960 942 961 return s.registerWithClient(publicURL, did, client) 943 962 }
-4
cmd/registry/main.go
··· 14 14 // Register our custom middleware 15 15 _ "atcr.io/pkg/middleware" 16 16 17 - "atcr.io/pkg/auth/exchange" 18 17 "atcr.io/pkg/auth/oauth" 19 - "atcr.io/pkg/auth/session" 20 18 "atcr.io/pkg/auth/token" 21 19 "atcr.io/pkg/middleware" 22 20 ) ··· 34 32 var _ = os.Stdout 35 33 var _ = time.Now 36 34 var _ = oauth.NewRefresher 37 - var _ = session.NewManager 38 35 var _ = token.NewIssuer 39 - var _ = exchange.NewHandler 40 36 var _ = middleware.SetGlobalRefresher
+25 -22
cmd/registry/serve.go
··· 17 17 "github.com/distribution/distribution/v3/registry/handlers" 18 18 "github.com/spf13/cobra" 19 19 20 - "atcr.io/pkg/auth/exchange" 21 20 "atcr.io/pkg/auth/oauth" 22 - "atcr.io/pkg/auth/session" 23 21 "atcr.io/pkg/auth/token" 24 22 "atcr.io/pkg/middleware" 25 23 26 24 // UI components 27 25 "atcr.io/pkg/appview" 26 + "atcr.io/pkg/appview/apikey" 28 27 "atcr.io/pkg/appview/db" 29 28 uihandlers "atcr.io/pkg/appview/handlers" 30 29 "atcr.io/pkg/appview/jetstream" ··· 93 92 return fmt.Errorf("failed to create OAuth store: %w", err) 94 93 } 95 94 96 - // 2. Create session manager with 30-day TTL 97 - // Use persistent secret so session tokens remain valid across container restarts 98 - secretPath := os.Getenv("ATCR_SESSION_SECRET_PATH") 99 - if secretPath == "" { 100 - // Default to same directory as tokens 101 - secretPath = filepath.Join(filepath.Dir(storagePath), "session-secret.key") 102 - } 103 - sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour) 95 + // 2. Create API key store 96 + apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json") 97 + apiKeyStore, err := apikey.NewStore(apiKeyStorePath) 104 98 if err != nil { 105 - return fmt.Errorf("failed to create session manager: %w", err) 99 + return fmt.Errorf("failed to create API key store: %w", err) 106 100 } 101 + fmt.Printf("Using API key storage path: %s\n", apiKeyStorePath) 107 102 108 103 // 3. Get base URL from config or environment 109 104 baseURL := os.Getenv("ATCR_BASE_URL") ··· 132 127 middleware.SetGlobalRefresher(refresher) 133 128 134 129 // 7. Initialize UI components (get session store for OAuth integration) 135 - uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL) 130 + uiDatabase, uiSessionStore, uiTemplates, uiRouter := initializeUI(config, refresher, baseURL, apiKeyStore) 136 131 137 132 // 8. Create OAuth server 138 - oauthServer := oauth.NewServer(oauthApp, sessionManager) 133 + oauthServer := oauth.NewServer(oauthApp) 139 134 // Connect server to refresher for cache invalidation 140 135 oauthServer.SetRefresher(refresher) 141 136 // Connect UI session store for web login ··· 192 187 // Extract default hold endpoint from middleware config 193 188 defaultHoldEndpoint := extractDefaultHoldEndpoint(config) 194 189 195 - // Basic Auth token endpoint (also supports session tokens) 196 - tokenHandler := token.NewHandler(issuer, sessionManager, defaultHoldEndpoint) 190 + // Basic Auth token endpoint (supports API keys and app passwords) 191 + tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint) 197 192 tokenHandler.RegisterRoutes(mux) 198 193 199 - // OAuth exchange endpoint (session token → registry JWT) 200 - exchangeHandler := exchange.NewHandler(issuer, sessionManager) 201 - exchangeHandler.RegisterRoutes(mux) 202 - 203 194 fmt.Printf("Auth endpoints enabled:\n") 204 - fmt.Printf(" - Basic Auth: /auth/token\n") 195 + fmt.Printf(" - Basic Auth: /auth/token (API keys + app passwords)\n") 205 196 fmt.Printf(" - OAuth: /auth/oauth/authorize\n") 206 197 fmt.Printf(" - OAuth: /auth/oauth/callback\n") 207 - fmt.Printf(" - Exchange: /auth/exchange\n") 208 198 } 209 199 210 200 // Create HTTP server ··· 336 326 } 337 327 338 328 // initializeUI initializes the web UI components 339 - func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { 329 + func initializeUI(config *configuration.Configuration, refresher *oauth.Refresher, baseURL string, apiKeyStore *apikey.Store) (*sql.DB, *appsession.Store, *template.Template, *mux.Router) { 340 330 // Check if UI is enabled (optional configuration) 341 331 uiEnabled := os.Getenv("ATCR_UI_ENABLED") 342 332 if uiEnabled == "false" { ··· 440 430 441 431 authRouter.Handle("/api/images/{repository}/manifests/{digest}", &uihandlers.DeleteManifestHandler{ 442 432 DB: database, 433 + }).Methods("DELETE") 434 + 435 + // API key management routes 436 + authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{ 437 + Store: apiKeyStore, 438 + }).Methods("POST") 439 + 440 + authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{ 441 + Store: apiKeyStore, 442 + }).Methods("GET") 443 + 444 + authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{ 445 + Store: apiKeyStore, 443 446 }).Methods("DELETE") 444 447 445 448 // Logout endpoint
+826
docs/API_KEY_MIGRATION.md
··· 1 + # API Key Migration Plan 2 + 3 + ## Overview 4 + 5 + Replace the session token system (used only by credential helper) with API keys that link to OAuth sessions. This simplifies authentication while maintaining all use cases. 6 + 7 + ## Current State 8 + 9 + ### Three Separate Auth Systems 10 + 11 + 1. **Session Tokens** (`pkg/auth/session/`) 12 + - JWT-like tokens: `<base64_claims>.<base64_signature>` 13 + - Created after OAuth callback, shown to user to copy 14 + - User manually pastes into credential helper config 15 + - Validated in `/auth/token` and `/auth/exchange` 16 + - 30-day TTL 17 + - **Problem:** Awkward UX, requires manual copy/paste 18 + 19 + 2. **UI Sessions** (`pkg/appview/session/`) 20 + - Cookie-based (`atcr_session`) 21 + - Random session ID, server-side store 22 + - 24-hour TTL 23 + - **Keep this - works well** 24 + 25 + 3. **App Password Auth** (via PDS) 26 + - Direct `com.atproto.server.createSession` call 27 + - No AppView involvement until token request 28 + - **Keep this - essential for non-UI users** 29 + 30 + ## Target State 31 + 32 + ### Two Auth Methods 33 + 34 + 1. **API Keys** (NEW - replaces session tokens) 35 + - Generated in UI after OAuth login 36 + - Format: `atcr_<32_bytes_base64>` 37 + - Linked to server-side OAuth refresh token 38 + - Multiple keys per user (laptop, CI/CD, etc.) 39 + - Revocable without re-auth 40 + 41 + 2. **App Passwords** (KEEP) 42 + - Direct PDS authentication 43 + - Works without UI/OAuth 44 + 45 + ### UI Sessions (UNCHANGED) 46 + - Cookie-based for web UI 47 + - Separate system, no changes needed 48 + 49 + --- 50 + 51 + ## Implementation Plan 52 + 53 + ### Phase 1: API Key System 54 + 55 + #### 1.1 Create API Key Store (`pkg/appview/apikey/store.go`) 56 + 57 + ```go 58 + package apikey 59 + 60 + import ( 61 + "crypto/rand" 62 + "encoding/base64" 63 + "encoding/json" 64 + "fmt" 65 + "os" 66 + "sync" 67 + "time" 68 + "golang.org/x/crypto/bcrypt" 69 + ) 70 + 71 + // APIKey represents a user's API key 72 + type APIKey struct { 73 + ID string `json:"id"` // UUID 74 + KeyHash string `json:"key_hash"` // bcrypt hash 75 + DID string `json:"did"` // Owner's DID 76 + Handle string `json:"handle"` // Owner's handle 77 + Name string `json:"name"` // User-provided name 78 + CreatedAt time.Time `json:"created_at"` 79 + LastUsed time.Time `json:"last_used"` 80 + } 81 + 82 + // Store manages API keys 83 + type Store struct { 84 + mu sync.RWMutex 85 + keys map[string]*APIKey // keyHash -> APIKey 86 + byDID map[string][]string // DID -> []keyHash 87 + filePath string // /var/lib/atcr/api-keys.json 88 + } 89 + 90 + // NewStore creates a new API key store 91 + func NewStore(filePath string) (*Store, error) 92 + 93 + // Generate creates a new API key and returns the plaintext key (shown once) 94 + func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error) 95 + 96 + // Validate checks if an API key is valid and returns the associated data 97 + func (s *Store) Validate(key string) (*APIKey, error) 98 + 99 + // List returns all API keys for a DID (without plaintext keys) 100 + func (s *Store) List(did string) []*APIKey 101 + 102 + // Delete removes an API key 103 + func (s *Store) Delete(did, keyID string) error 104 + 105 + // UpdateLastUsed updates the last used timestamp 106 + func (s *Store) UpdateLastUsed(keyHash string) error 107 + ``` 108 + 109 + **Key Generation:** 110 + ```go 111 + func (s *Store) Generate(did, handle, name string) (string, string, error) { 112 + // Generate 32 random bytes 113 + b := make([]byte, 32) 114 + if _, err := rand.Read(b); err != nil { 115 + return "", "", err 116 + } 117 + 118 + // Format: atcr_<base64> 119 + key := "atcr_" + base64.RawURLEncoding.EncodeToString(b) 120 + 121 + // Hash for storage 122 + keyHash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost) 123 + if err != nil { 124 + return "", "", err 125 + } 126 + 127 + // Generate ID 128 + keyID := generateUUID() 129 + 130 + apiKey := &APIKey{ 131 + ID: keyID, 132 + KeyHash: string(keyHash), 133 + DID: did, 134 + Handle: handle, 135 + Name: name, 136 + CreatedAt: time.Now(), 137 + LastUsed: time.Time{}, // Never used yet 138 + } 139 + 140 + s.mu.Lock() 141 + s.keys[string(keyHash)] = apiKey 142 + s.byDID[did] = append(s.byDID[did], string(keyHash)) 143 + s.mu.Unlock() 144 + 145 + s.save() 146 + 147 + // Return plaintext key (only time it's available) 148 + return key, keyID, nil 149 + } 150 + ``` 151 + 152 + **Key Validation:** 153 + ```go 154 + func (s *Store) Validate(key string) (*APIKey, error) { 155 + s.mu.RLock() 156 + defer s.mu.RUnlock() 157 + 158 + // Try to match against all stored hashes 159 + for hash, apiKey := range s.keys { 160 + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil { 161 + // Update last used asynchronously 162 + go s.UpdateLastUsed(hash) 163 + return apiKey, nil 164 + } 165 + } 166 + 167 + return nil, fmt.Errorf("invalid API key") 168 + } 169 + ``` 170 + 171 + #### 1.2 Add API Key Handlers (`pkg/appview/handlers/apikeys.go`) 172 + 173 + ```go 174 + package handlers 175 + 176 + import ( 177 + "encoding/json" 178 + "html/template" 179 + "net/http" 180 + "github.com/gorilla/mux" 181 + "atcr.io/pkg/appview/apikey" 182 + "atcr.io/pkg/appview/middleware" 183 + ) 184 + 185 + // GenerateAPIKeyHandler handles POST /api/keys 186 + type GenerateAPIKeyHandler struct { 187 + Store *apikey.Store 188 + } 189 + 190 + func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 191 + user := middleware.GetUser(r) 192 + if user == nil { 193 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 194 + return 195 + } 196 + 197 + name := r.FormValue("name") 198 + if name == "" { 199 + name = "Unnamed Key" 200 + } 201 + 202 + key, keyID, err := h.Store.Generate(user.DID, user.Handle, name) 203 + if err != nil { 204 + http.Error(w, "Failed to generate key", http.StatusInternalServerError) 205 + return 206 + } 207 + 208 + // Return key (shown once!) 209 + w.Header().Set("Content-Type", "application/json") 210 + json.NewEncoder(w).Encode(map[string]string{ 211 + "id": keyID, 212 + "key": key, 213 + }) 214 + } 215 + 216 + // ListAPIKeysHandler handles GET /api/keys 217 + type ListAPIKeysHandler struct { 218 + Store *apikey.Store 219 + } 220 + 221 + func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 222 + user := middleware.GetUser(r) 223 + if user == nil { 224 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 225 + return 226 + } 227 + 228 + keys := h.Store.List(user.DID) 229 + 230 + w.Header().Set("Content-Type", "application/json") 231 + json.NewEncoder(w).Encode(keys) 232 + } 233 + 234 + // DeleteAPIKeyHandler handles DELETE /api/keys/{id} 235 + type DeleteAPIKeyHandler struct { 236 + Store *apikey.Store 237 + } 238 + 239 + func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 240 + user := middleware.GetUser(r) 241 + if user == nil { 242 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 243 + return 244 + } 245 + 246 + vars := mux.Vars(r) 247 + keyID := vars["id"] 248 + 249 + if err := h.Store.Delete(user.DID, keyID); err != nil { 250 + http.Error(w, "Failed to delete key", http.StatusInternalServerError) 251 + return 252 + } 253 + 254 + w.WriteHeader(http.StatusNoContent) 255 + } 256 + ``` 257 + 258 + ### Phase 2: Update Token Handler 259 + 260 + #### 2.1 Modify `/auth/token` Handler (`pkg/auth/token/handler.go`) 261 + 262 + ```go 263 + type Handler struct { 264 + issuer *Issuer 265 + validator *atproto.SessionValidator 266 + apiKeyStore *apikey.Store // NEW 267 + defaultHoldEndpoint string 268 + } 269 + 270 + func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 271 + username, password, ok := r.BasicAuth() 272 + if !ok { 273 + return unauthorized 274 + } 275 + 276 + var did, handle, accessToken string 277 + 278 + // 1. Check if it's an API key (NEW) 279 + if strings.HasPrefix(password, "atcr_") { 280 + apiKey, err := h.apiKeyStore.Validate(password) 281 + if err != nil { 282 + fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err) 283 + return unauthorized 284 + } 285 + 286 + did = apiKey.DID 287 + handle = apiKey.Handle 288 + fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle) 289 + 290 + // API key is linked to OAuth session 291 + // OAuth refresher will provide access token when needed via middleware 292 + } 293 + // 2. Try app password (direct PDS) 294 + else { 295 + did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 296 + if err != nil { 297 + fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err) 298 + return unauthorized 299 + } 300 + 301 + fmt.Printf("DEBUG [token/handler]: App password validated, DID=%s\n", did) 302 + 303 + // Cache access token for manifest operations 304 + auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour) 305 + 306 + // Ensure profile exists 307 + // ... existing code ... 308 + } 309 + 310 + // Rest of handler: validate access, issue JWT, etc. 311 + // ... existing code ... 312 + } 313 + ``` 314 + 315 + **Key Changes:** 316 + - Remove session token validation (`sessionManager.Validate()`) 317 + - Add API key check as first priority 318 + - Keep app password as fallback 319 + - API keys use OAuth refresher (server-side), app passwords use token cache (client-side) 320 + 321 + #### 2.2 Remove `/auth/exchange` Endpoint 322 + 323 + The `/auth/exchange` endpoint was only used for exchanging session tokens for registry JWTs. With API keys, this is no longer needed. 324 + 325 + **Files to delete:** 326 + - `pkg/auth/exchange/handler.go` 327 + 328 + **Files to update:** 329 + - `cmd/registry/serve.go` - Remove exchange handler registration 330 + 331 + ### Phase 3: Update UI 332 + 333 + #### 3.1 Add API Keys Section to Settings Page 334 + 335 + **Template** (`pkg/appview/templates/settings.html`): 336 + 337 + ```html 338 + <!-- Add after existing profile settings --> 339 + <section class="api-keys"> 340 + <h2>API Keys</h2> 341 + <p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p> 342 + 343 + <!-- Generate New Key --> 344 + <div class="generate-key"> 345 + <h3>Generate New API Key</h3> 346 + <form id="generate-key-form"> 347 + <input type="text" id="key-name" placeholder="Key name (e.g., My Laptop)" required> 348 + <button type="submit">Generate Key</button> 349 + </form> 350 + </div> 351 + 352 + <!-- Key Generated Modal (shown once) --> 353 + <div id="key-modal" class="modal hidden"> 354 + <div class="modal-content"> 355 + <h3>✓ API Key Generated!</h3> 356 + <p><strong>Copy this key now - it won't be shown again:</strong></p> 357 + <div class="key-display"> 358 + <code id="generated-key"></code> 359 + <button onclick="copyKey()">Copy to Clipboard</button> 360 + </div> 361 + <div class="usage-instructions"> 362 + <h4>Using with Docker:</h4> 363 + <pre>docker login atcr.io -u <span class="handle">{{.Profile.Handle}}</span> -p <span class="key-placeholder">[paste key here]</span></pre> 364 + </div> 365 + <button onclick="closeModal()">Done</button> 366 + </div> 367 + </div> 368 + 369 + <!-- Existing Keys List --> 370 + <div class="keys-list"> 371 + <h3>Your API Keys</h3> 372 + <table> 373 + <thead> 374 + <tr> 375 + <th>Name</th> 376 + <th>Created</th> 377 + <th>Last Used</th> 378 + <th>Actions</th> 379 + </tr> 380 + </thead> 381 + <tbody id="keys-table"> 382 + <!-- Populated via JavaScript --> 383 + </tbody> 384 + </table> 385 + </div> 386 + </section> 387 + 388 + <script> 389 + // Generate key 390 + document.getElementById('generate-key-form').addEventListener('submit', async (e) => { 391 + e.preventDefault(); 392 + const name = document.getElementById('key-name').value; 393 + 394 + const resp = await fetch('/api/keys', { 395 + method: 'POST', 396 + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 397 + body: `name=${encodeURIComponent(name)}` 398 + }); 399 + 400 + const data = await resp.json(); 401 + 402 + // Show key in modal (only time it's available) 403 + document.getElementById('generated-key').textContent = data.key; 404 + document.getElementById('key-modal').classList.remove('hidden'); 405 + 406 + // Refresh keys list 407 + loadKeys(); 408 + }); 409 + 410 + // Copy key to clipboard 411 + function copyKey() { 412 + const key = document.getElementById('generated-key').textContent; 413 + navigator.clipboard.writeText(key); 414 + alert('Copied to clipboard!'); 415 + } 416 + 417 + // Load existing keys 418 + async function loadKeys() { 419 + const resp = await fetch('/api/keys'); 420 + const keys = await resp.json(); 421 + 422 + const tbody = document.getElementById('keys-table'); 423 + tbody.innerHTML = keys.map(key => ` 424 + <tr> 425 + <td>${key.name}</td> 426 + <td>${new Date(key.created_at).toLocaleDateString()}</td> 427 + <td>${key.last_used ? new Date(key.last_used).toLocaleDateString() : 'Never'}</td> 428 + <td><button onclick="deleteKey('${key.id}')">Revoke</button></td> 429 + </tr> 430 + `).join(''); 431 + } 432 + 433 + // Delete key 434 + async function deleteKey(id) { 435 + if (!confirm('Are you sure you want to revoke this key?')) return; 436 + 437 + await fetch(`/api/keys/${id}`, { method: 'DELETE' }); 438 + loadKeys(); 439 + } 440 + 441 + // Load keys on page load 442 + loadKeys(); 443 + </script> 444 + 445 + <style> 446 + .modal.hidden { display: none; } 447 + .modal { 448 + position: fixed; 449 + top: 0; 450 + left: 0; 451 + width: 100%; 452 + height: 100%; 453 + background: rgba(0,0,0,0.5); 454 + display: flex; 455 + align-items: center; 456 + justify-content: center; 457 + } 458 + .modal-content { 459 + background: white; 460 + padding: 2rem; 461 + border-radius: 8px; 462 + max-width: 600px; 463 + } 464 + .key-display { 465 + background: #f5f5f5; 466 + padding: 1rem; 467 + margin: 1rem 0; 468 + border-radius: 4px; 469 + } 470 + .key-display code { 471 + word-break: break-all; 472 + font-size: 14px; 473 + } 474 + .usage-instructions { 475 + margin-top: 1rem; 476 + padding: 1rem; 477 + background: #e3f2fd; 478 + border-radius: 4px; 479 + } 480 + .usage-instructions pre { 481 + background: #263238; 482 + color: #aed581; 483 + padding: 1rem; 484 + border-radius: 4px; 485 + overflow-x: auto; 486 + } 487 + .handle { color: #ffab40; } 488 + .key-placeholder { color: #64b5f6; } 489 + </style> 490 + ``` 491 + 492 + #### 3.2 Register API Key Routes (`cmd/registry/serve.go`) 493 + 494 + ```go 495 + // In initializeUI() function, add: 496 + 497 + // API key management routes (authenticated) 498 + authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{ 499 + Store: apiKeyStore, 500 + }).Methods("POST") 501 + 502 + authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{ 503 + Store: apiKeyStore, 504 + }).Methods("GET") 505 + 506 + authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{ 507 + Store: apiKeyStore, 508 + }).Methods("DELETE") 509 + ``` 510 + 511 + ### Phase 4: Update Credential Helper 512 + 513 + #### 4.1 Simplify Configuration (`cmd/credential-helper/main.go`) 514 + 515 + ```go 516 + // SessionStore becomes CredentialStore 517 + type CredentialStore struct { 518 + Handle string `json:"handle"` 519 + APIKey string `json:"api_key"` 520 + AppViewURL string `json:"appview_url"` 521 + } 522 + 523 + func handleConfigure(handle string) { 524 + fmt.Println("ATCR Credential Helper Configuration") 525 + fmt.Println("=====================================") 526 + fmt.Println() 527 + fmt.Println("You need an API key from the ATCR web UI.") 528 + fmt.Println() 529 + 530 + appViewURL := os.Getenv("ATCR_APPVIEW_URL") 531 + if appViewURL == "" { 532 + appViewURL = defaultAppViewURL 533 + } 534 + 535 + // Auto-open settings page 536 + settingsURL := appViewURL + "/settings" 537 + fmt.Printf("Opening settings page: %s\n", settingsURL) 538 + fmt.Println("Log in and generate an API key if you haven't already.") 539 + fmt.Println() 540 + 541 + if err := oauth.OpenBrowser(settingsURL); err != nil { 542 + fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL) 543 + } 544 + 545 + // Prompt for credentials 546 + if handle == "" { 547 + fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ") 548 + fmt.Scanln(&handle) 549 + } else { 550 + fmt.Printf("Using handle: %s\n", handle) 551 + } 552 + 553 + fmt.Print("Enter your API key (from settings page): ") 554 + var apiKey string 555 + fmt.Scanln(&apiKey) 556 + 557 + // Validate key format 558 + if !strings.HasPrefix(apiKey, "atcr_") { 559 + fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n") 560 + os.Exit(1) 561 + } 562 + 563 + // Save credentials 564 + creds := &CredentialStore{ 565 + Handle: handle, 566 + APIKey: apiKey, 567 + AppViewURL: appViewURL, 568 + } 569 + 570 + if err := saveCredentials(getCredentialsPath(), creds); err != nil { 571 + fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err) 572 + os.Exit(1) 573 + } 574 + 575 + fmt.Println() 576 + fmt.Println("✓ Configuration complete!") 577 + fmt.Println("You can now use docker push/pull with atcr.io") 578 + } 579 + 580 + func handleGet() { 581 + var serverURL string 582 + fmt.Fscanln(os.Stdin, &serverURL) 583 + 584 + // Load credentials 585 + creds, err := loadCredentials(getCredentialsPath()) 586 + if err != nil { 587 + fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err) 588 + fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n") 589 + os.Exit(1) 590 + } 591 + 592 + // Return credentials for Docker 593 + // Docker will send these as Basic Auth to /auth/token 594 + response := Credentials{ 595 + ServerURL: serverURL, 596 + Username: creds.Handle, 597 + Secret: creds.APIKey, // API key as password 598 + } 599 + 600 + json.NewEncoder(os.Stdout).Encode(response) 601 + } 602 + ``` 603 + 604 + **File Rename:** 605 + - `~/.atcr/session.json` → `~/.atcr/credentials.json` 606 + 607 + ### Phase 5: Remove Session Token System 608 + 609 + #### 5.1 Delete Session Token Files 610 + 611 + **Files to delete:** 612 + - `pkg/auth/session/handler.go` 613 + - `pkg/auth/exchange/handler.go` 614 + 615 + #### 5.2 Update OAuth Server (`pkg/auth/oauth/server.go`) 616 + 617 + **Remove session token creation:** 618 + ```go 619 + // OLD (delete this): 620 + sessionToken, err := s.sessionManager.Create(did, handle) 621 + if err != nil { 622 + s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err)) 623 + return 624 + } 625 + 626 + // Check if this is a UI login... 627 + if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { 628 + // UI flow... 629 + } else { 630 + // Render success page with session token (for credential helper) 631 + s.renderSuccess(w, sessionToken, handle) 632 + } 633 + ``` 634 + 635 + **NEW (replace with):** 636 + ```go 637 + // Check if this is a UI login 638 + if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { 639 + // Create UI session 640 + uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour) 641 + // ... set cookie, redirect ... 642 + } else { 643 + // Non-UI flow: redirect to settings to get API key 644 + s.renderRedirectToSettings(w, handle) 645 + } 646 + ``` 647 + 648 + **Add redirect to settings template:** 649 + ```go 650 + func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) { 651 + tmpl := template.Must(template.New("redirect").Parse(` 652 + <!DOCTYPE html> 653 + <html> 654 + <head> 655 + <title>Authorization Successful - ATCR</title> 656 + <meta http-equiv="refresh" content="3;url=/settings"> 657 + </head> 658 + <body> 659 + <h1>✓ Authorization Successful!</h1> 660 + <p>Redirecting to settings page to generate your API key...</p> 661 + <p>If not redirected, <a href="/settings">click here</a>.</p> 662 + </body> 663 + </html> 664 + `)) 665 + w.Header().Set("Content-Type", "text/html") 666 + tmpl.Execute(w, nil) 667 + } 668 + ``` 669 + 670 + #### 5.3 Update Server Constructor 671 + 672 + ```go 673 + // Remove sessionManager parameter 674 + func NewServer(app *App) *Server { 675 + return &Server{ 676 + app: app, 677 + } 678 + } 679 + ``` 680 + 681 + #### 5.4 Update Registry Initialization (`cmd/registry/serve.go`) 682 + 683 + ```go 684 + // REMOVE session manager creation: 685 + // sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour) 686 + 687 + // Create API key store 688 + apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json") 689 + apiKeyStore, err := apikey.NewStore(apiKeyStorePath) 690 + if err != nil { 691 + return fmt.Errorf("failed to create API key store: %w", err) 692 + } 693 + 694 + // OAuth server doesn't need session manager anymore 695 + oauthServer := oauth.NewServer(oauthApp) 696 + oauthServer.SetRefresher(refresher) 697 + if uiSessionStore != nil { 698 + oauthServer.SetUISessionStore(uiSessionStore) 699 + } 700 + 701 + // Token handler gets API key store instead of session manager 702 + if issuer != nil { 703 + tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint) 704 + tokenHandler.RegisterRoutes(mux) 705 + 706 + // Remove exchange handler registration (no longer needed) 707 + } 708 + ``` 709 + 710 + --- 711 + 712 + ## Migration Path 713 + 714 + ### For Existing Users 715 + 716 + **Option 1: Smooth Migration (Recommended)** 717 + 1. Keep session token validation temporarily with deprecation warning 718 + 2. When session token is used, log warning and return special response header 719 + 3. Docker client shows warning: "Session tokens deprecated, please regenerate API key" 720 + 4. Remove session token support in next major version 721 + 722 + **Option 2: Hard Cutover** 723 + 1. Deploy new version with API keys 724 + 2. Session tokens stop working immediately 725 + 3. Users must reconfigure: `docker-credential-atcr configure` 726 + 4. Cleaner but disruptive 727 + 728 + ### Rollout Plan 729 + 730 + **Week 1: Deploy API Keys** 731 + - Add API key system 732 + - Keep session token validation 733 + - Add deprecation notice to OAuth callback 734 + 735 + **Week 2-4: Migration Period** 736 + - Monitor API key adoption 737 + - Email users about migration 738 + - Provide migration guide 739 + 740 + **Week 5: Remove Session Tokens** 741 + - Delete session token code 742 + - Force users to API keys 743 + 744 + --- 745 + 746 + ## Testing Plan 747 + 748 + ### Unit Tests 749 + 750 + 1. **API Key Store** 751 + - Test key generation (format, uniqueness) 752 + - Test key validation (correct/incorrect keys) 753 + - Test bcrypt hashing 754 + - Test key listing/deletion 755 + 756 + 2. **Token Handler** 757 + - Test API key authentication 758 + - Test app password authentication 759 + - Test invalid credentials 760 + - Test key format validation 761 + 762 + ### Integration Tests 763 + 764 + 1. **Full Auth Flow** 765 + - UI login → OAuth → API key generation 766 + - Credential helper → API key → registry JWT 767 + - App password → registry JWT 768 + 769 + 2. **Docker Client Tests** 770 + - `docker login -u handle -p api_key` 771 + - `docker login -u handle -p app_password` 772 + - `docker push` with API key 773 + - `docker pull` with API key 774 + 775 + ### Security Tests 776 + 777 + 1. **Key Security** 778 + - Verify bcrypt hashing (not plaintext storage) 779 + - Test key shown only once 780 + - Test key revocation 781 + - Test unauthorized key access 782 + 783 + 2. **OAuth Security** 784 + - Verify API key links to correct OAuth session 785 + - Test expired refresh token handling 786 + - Test multiple keys for same user 787 + 788 + --- 789 + 790 + ## Files Changed 791 + 792 + ### New Files 793 + - `pkg/appview/apikey/store.go` - API key storage and validation 794 + - `pkg/appview/handlers/apikeys.go` - API key HTTP handlers 795 + - `docs/API_KEY_MIGRATION.md` - This document 796 + 797 + ### Modified Files 798 + - `pkg/auth/token/handler.go` - Add API key validation, remove session token 799 + - `pkg/auth/oauth/server.go` - Remove session token creation, redirect to settings 800 + - `pkg/appview/handlers/settings.go` - Add API key management UI 801 + - `pkg/appview/templates/settings.html` - Add API key section 802 + - `cmd/credential-helper/main.go` - Simplify to use API keys 803 + - `cmd/registry/serve.go` - Initialize API key store, remove session manager 804 + 805 + ### Deleted Files 806 + - `pkg/auth/session/handler.go` - Session token system 807 + - `pkg/auth/exchange/handler.go` - Exchange endpoint (no longer needed) 808 + 809 + --- 810 + 811 + ## Advantages 812 + 813 + ✅ **Simpler Auth:** Two methods instead of three (API keys + app passwords) 814 + ✅ **Better UX:** No manual copy/paste of session tokens 815 + ✅ **Multiple Keys:** Users can have laptop key, CI key, etc. 816 + ✅ **Revocable:** Revoke individual keys without re-auth 817 + ✅ **Server-Side OAuth:** Refresh tokens stay on server, not in client files 818 + ✅ **Familiar Pattern:** Matches AWS ECR, GitHub tokens, etc. 819 + 820 + ## Backward Compatibility 821 + 822 + ⚠️ **Breaking Change:** Session tokens will stop working 823 + ✅ **App passwords:** Still work (no changes) 824 + ✅ **UI sessions:** Still work (separate system) 825 + 826 + **Migration Required:** Users with session tokens must run `docker-credential-atcr configure` again to get API keys.
+1 -1
docs/APPVIEW-UI-IMPLEMENTATION.md
··· 758 758 } 759 759 760 760 // Update profile in PDS 761 - err := h.ATProtoClient.UpdateProfile(user.DID, map[string]interface{}{ 761 + err := h.ATProtoClient.UpdateProfile(user.DID, map[string]any{ 762 762 "defaultHold": holdEndpoint, 763 763 }) 764 764
+36 -2
go.mod
··· 7 7 github.com/distribution/distribution/v3 v3.0.0 8 8 github.com/distribution/reference v0.6.0 9 9 github.com/golang-jwt/jwt/v5 v5.2.2 10 + github.com/google/uuid v1.6.0 10 11 github.com/gorilla/mux v1.8.1 11 12 github.com/gorilla/websocket v1.5.3 12 13 github.com/klauspost/compress v1.18.0 13 14 github.com/mattn/go-sqlite3 v1.14.32 14 15 github.com/opencontainers/go-digest v1.0.0 15 16 github.com/spf13/cobra v1.8.0 17 + golang.org/x/crypto v0.39.0 16 18 ) 17 19 18 20 require ( ··· 31 33 github.com/go-jose/go-jose/v4 v4.1.2 // indirect 32 34 github.com/go-logr/logr v1.4.2 // indirect 33 35 github.com/go-logr/stdr v1.2.2 // indirect 36 + github.com/gogo/protobuf v1.3.2 // indirect 34 37 github.com/google/go-cmp v0.7.0 // indirect 35 38 github.com/google/go-querystring v1.1.0 // indirect 36 - github.com/google/uuid v1.6.0 // indirect 37 39 github.com/gorilla/handlers v1.5.2 // indirect 38 40 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect 41 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 42 + github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 43 + github.com/hashicorp/golang-lru v1.0.2 // indirect 39 44 github.com/hashicorp/golang-lru/arc/v2 v2.0.6 // indirect 40 45 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 41 46 github.com/inconshreveable/mousetrap v1.1.0 // indirect 47 + github.com/ipfs/bbloom v0.0.4 // indirect 48 + github.com/ipfs/go-block-format v0.2.0 // indirect 49 + github.com/ipfs/go-cid v0.4.1 // indirect 50 + github.com/ipfs/go-datastore v0.6.0 // indirect 51 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 52 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 53 + github.com/ipfs/go-ipfs-util v0.0.3 // indirect 54 + github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 55 + github.com/ipfs/go-ipld-format v0.6.0 // indirect 56 + github.com/ipfs/go-log v1.0.5 // indirect 57 + github.com/ipfs/go-log/v2 v2.5.1 // indirect 58 + github.com/ipfs/go-metrics-interface v0.0.1 // indirect 59 + github.com/jbenet/goprocess v0.1.4 // indirect 42 60 github.com/jmespath/go-jmespath v0.4.0 // indirect 61 + github.com/klauspost/cpuid/v2 v2.2.7 // indirect 62 + github.com/mattn/go-isatty v0.0.20 // indirect 63 + github.com/minio/sha256-simd v1.0.1 // indirect 43 64 github.com/mr-tron/base58 v1.2.0 // indirect 65 + github.com/multiformats/go-base32 v0.1.0 // indirect 66 + github.com/multiformats/go-base36 v0.2.0 // indirect 67 + github.com/multiformats/go-multibase v0.2.0 // indirect 68 + github.com/multiformats/go-multihash v0.2.3 // indirect 69 + github.com/multiformats/go-varint v0.0.7 // indirect 44 70 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 45 71 github.com/opencontainers/image-spec v1.1.0 // indirect 72 + github.com/opentracing/opentracing-go v1.2.0 // indirect 73 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 46 74 github.com/prometheus/client_golang v1.20.5 // indirect 47 75 github.com/prometheus/client_model v0.6.1 // indirect 48 76 github.com/prometheus/common v0.60.1 // indirect ··· 51 79 github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect 52 80 github.com/redis/go-redis/v9 v9.7.3 // indirect 53 81 github.com/sirupsen/logrus v1.9.3 // indirect 82 + github.com/spaolacci/murmur3 v1.1.0 // indirect 54 83 github.com/spf13/pflag v1.0.5 // indirect 84 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 55 85 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 56 86 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 57 87 go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect ··· 76 106 go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect 77 107 go.opentelemetry.io/otel/trace v1.32.0 // indirect 78 108 go.opentelemetry.io/proto/otlp v1.3.1 // indirect 79 - golang.org/x/crypto v0.39.0 // indirect 109 + go.uber.org/atomic v1.11.0 // indirect 110 + go.uber.org/multierr v1.11.0 // indirect 111 + go.uber.org/zap v1.26.0 // indirect 80 112 golang.org/x/net v0.37.0 // indirect 81 113 golang.org/x/sync v0.15.0 // indirect 82 114 golang.org/x/sys v0.33.0 // indirect 83 115 golang.org/x/text v0.26.0 // indirect 84 116 golang.org/x/time v0.6.0 // indirect 117 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 85 118 google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect 86 119 google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect 87 120 google.golang.org/grpc v1.68.0 // indirect 88 121 google.golang.org/protobuf v1.35.1 // indirect 89 122 gopkg.in/yaml.v2 v2.4.0 // indirect 123 + lukechampine.com/blake3 v1.2.1 // indirect 90 124 )
+90
go.sum
··· 1 1 github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8 h1:d+pBUmsteW5tM87xmVXHZ4+LibHRFn40SPAoZJOg2ak= 2 2 github.com/AdaLogics/go-fuzz-headers v0.0.0-20221103172237-443f56ff4ba8/go.mod h1:i9fr2JpcEcY/IHEvzCM3qXUZYOQHgR89dt4es1CgMhc= 3 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 4 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 4 5 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 5 6 github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= 6 7 github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 8 + github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 7 9 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 8 10 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 9 11 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= ··· 27 29 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 28 30 github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 29 31 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 32 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 30 33 github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 31 34 github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= 32 35 github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= ··· 58 61 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 59 62 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 60 63 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 64 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 61 65 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 62 66 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 63 67 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= ··· 76 80 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 77 81 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 78 82 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 83 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 79 84 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 80 85 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 86 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 87 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 81 88 github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= 82 89 github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 83 90 github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= ··· 88 95 github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= 89 96 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 90 97 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 98 + github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 99 + github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 91 100 github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 92 101 github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 93 102 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= ··· 106 115 github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 107 116 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 108 117 github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 118 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 119 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 109 120 github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 110 121 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 111 122 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= ··· 118 129 github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 119 130 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 120 131 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 132 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 121 133 github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 122 134 github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 123 135 github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 124 136 github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 137 + github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 125 138 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 126 139 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 127 140 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= ··· 130 143 github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 131 144 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 132 145 github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 146 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 147 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 133 148 github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 149 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 150 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 134 151 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 135 152 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 136 153 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 137 154 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 138 155 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 139 156 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 157 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 140 158 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 141 159 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 160 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 161 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 142 162 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 143 163 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 144 164 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 145 165 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 166 + github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 146 167 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 147 168 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 148 169 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= ··· 176 197 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 177 198 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 178 199 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 200 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 179 201 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 180 202 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 181 203 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= ··· 205 227 github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= 206 228 github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 207 229 github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 230 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 208 231 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 209 232 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 233 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 210 234 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 235 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 211 236 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 212 237 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 213 238 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 239 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 240 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 241 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 242 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 214 243 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 215 244 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 216 245 github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= ··· 221 250 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 222 251 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 223 252 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 253 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 224 254 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 225 255 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 226 256 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 257 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 258 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 259 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 227 260 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 228 261 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 262 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 263 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 264 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 229 265 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 230 266 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 231 267 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 274 310 go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= 275 311 go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= 276 312 go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= 313 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 314 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 277 315 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 278 316 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 317 + go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 279 318 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 280 319 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 320 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 321 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 281 322 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 282 323 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 324 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 325 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 326 + go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 283 327 go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 284 328 go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 285 329 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 286 330 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 331 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 332 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 333 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 287 334 golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 288 335 golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 336 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 337 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 338 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 339 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 340 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 289 341 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 342 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 343 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 290 344 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 345 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 346 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 347 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 348 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 291 349 golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 292 350 golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 293 351 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 294 352 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 353 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 354 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 355 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 356 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 295 357 golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 296 358 golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 297 359 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 298 360 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 299 361 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 362 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 300 363 golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 364 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 365 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 366 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 367 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 368 + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 301 369 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 370 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 371 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 302 372 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 303 373 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 374 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 304 375 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 376 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 305 377 golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 306 378 golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 307 379 golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 308 380 golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 381 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 382 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 383 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 384 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 385 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 386 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 387 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 388 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 389 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 390 + golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 391 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 392 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 309 393 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 394 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 310 395 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 311 396 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 312 397 google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= ··· 319 404 google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 320 405 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 321 406 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 407 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 322 408 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 323 409 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 410 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 324 411 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 412 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 325 413 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 326 414 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 327 415 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 328 416 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 417 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 329 418 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 330 419 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 420 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 331 421 lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 332 422 lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+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 + }
+3 -3
pkg/appview/db/queries.go
··· 16 16 JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest 17 17 ` 18 18 19 - args := []interface{}{} 19 + args := []any{} 20 20 21 21 if userFilter != "" { 22 22 query += " WHERE u.handle = ? OR u.did = ?" ··· 43 43 44 44 // Get total count 45 45 countQuery := "SELECT COUNT(*) FROM tags t JOIN users u ON t.did = u.did" 46 - countArgs := []interface{}{} 46 + countArgs := []any{} 47 47 48 48 if userFilter != "" { 49 49 countQuery += " WHERE u.handle = ? OR u.did = ?" ··· 228 228 229 229 // Build placeholders for IN clause 230 230 placeholders := make([]string, len(keepDigests)) 231 - args := []interface{}{did} 231 + args := []any{did} 232 232 for i, digest := range keepDigests { 233 233 placeholders[i] = "?" 234 234 args = append(args, digest)
+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 + }
+14 -12
pkg/appview/handlers/settings.go
··· 28 28 // Get OAuth session for the user 29 29 session, err := h.Refresher.GetSession(r.Context(), user.DID) 30 30 if err != nil { 31 - http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError) 31 + // OAuth session not found or expired - redirect to re-authenticate 32 + fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err) 33 + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) 32 34 return 33 35 } 34 36 35 - // Extract access token and HTTP client from session 36 - accessToken, _ := session.GetHostAccessData() 37 - httpClient := session.APIClient().Client 37 + // Use indigo's API client directly - it handles all auth automatically 38 + apiClient := session.APIClient() 38 39 39 - // Create ATProto client with indigo's DPoP-configured HTTP client 40 - client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient) 40 + // Create ATProto client with indigo's XRPC client 41 + client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 41 42 42 43 // Fetch sailor profile 43 44 profile, err := atproto.GetProfile(r.Context(), client) ··· 93 94 // Get OAuth session for the user 94 95 session, err := h.Refresher.GetSession(r.Context(), user.DID) 95 96 if err != nil { 96 - http.Error(w, "Failed to get session: "+err.Error(), http.StatusInternalServerError) 97 + // OAuth session not found or expired - redirect to re-authenticate 98 + fmt.Printf("WARNING [settings]: OAuth session not found for %s: %v - redirecting to login\n", user.DID, err) 99 + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) 97 100 return 98 101 } 99 102 100 - // Extract access token and HTTP client from session 101 - accessToken, _ := session.GetHostAccessData() 102 - httpClient := session.APIClient().Client 103 + // Use indigo's API client directly - it handles all auth automatically 104 + apiClient := session.APIClient() 103 105 104 - // Create ATProto client with indigo's DPoP-configured HTTP client 105 - client := atproto.NewClientWithHTTPClient(user.PDSEndpoint, user.DID, accessToken, httpClient) 106 + // Create ATProto client with indigo's XRPC client 107 + client := atproto.NewClientWithIndigoClient(user.PDSEndpoint, user.DID, apiClient) 106 108 107 109 // Fetch existing profile or create new one 108 110 profile, err := atproto.GetProfile(r.Context(), client)
+49 -13
pkg/appview/jetstream/backfill.go
··· 8 8 "strings" 9 9 "time" 10 10 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + 11 14 "atcr.io/pkg/appview/db" 12 15 "atcr.io/pkg/atproto" 13 16 ) 14 17 15 18 // BackfillWorker uses com.atproto.sync.listReposByCollection to backfill historical data 16 19 type BackfillWorker struct { 17 - db *sql.DB 18 - client *atproto.Client 19 - resolver *atproto.Resolver 20 + db *sql.DB 21 + client *atproto.Client 22 + directory identity.Directory 20 23 } 21 24 22 25 // BackfillState tracks backfill progress ··· 36 39 client := atproto.NewClient(relayEndpoint, "", "") 37 40 38 41 return &BackfillWorker{ 39 - db: database, 40 - client: client, // This points to the relay 41 - resolver: atproto.NewResolver(), 42 + db: database, 43 + client: client, // This points to the relay 44 + directory: identity.DefaultDirectory(), 42 45 }, nil 43 46 } 44 47 ··· 117 120 } 118 121 119 122 // Resolve DID to get user's PDS endpoint 120 - _, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did) 123 + didParsed, err := syntax.ParseDID(did) 124 + if err != nil { 125 + return 0, fmt.Errorf("invalid DID %s: %w", did, err) 126 + } 127 + 128 + ident, err := b.directory.LookupDID(ctx, didParsed) 121 129 if err != nil { 122 130 return 0, fmt.Errorf("failed to resolve DID to PDS: %w", err) 131 + } 132 + 133 + pdsEndpoint := ident.PDSEndpoint() 134 + if pdsEndpoint == "" { 135 + return 0, fmt.Errorf("no PDS endpoint found for DID %s", did) 123 136 } 124 137 125 138 // Create a client for this user's PDS ··· 314 327 } 315 328 316 329 // Resolve DID to get handle and PDS endpoint 317 - resolvedDID, pdsEndpoint, err := b.resolver.ResolveIdentity(ctx, did) 330 + didParsed, err := syntax.ParseDID(did) 318 331 if err != nil { 319 332 // Fallback: use DID as handle 320 - resolvedDID = did 321 - pdsEndpoint = "https://bsky.social" 333 + user := &db.User{ 334 + DID: did, 335 + Handle: did, 336 + PDSEndpoint: "https://bsky.social", 337 + LastSeen: time.Now(), 338 + } 339 + return db.UpsertUser(b.db, user) 322 340 } 323 341 324 - // Get handle from DID document 325 - handle, err := b.resolver.ResolveHandleFromDID(ctx, resolvedDID) 342 + ident, err := b.directory.LookupDID(ctx, didParsed) 326 343 if err != nil { 327 - handle = resolvedDID // Fallback to DID 344 + // Fallback: use DID as handle 345 + user := &db.User{ 346 + DID: did, 347 + Handle: did, 348 + PDSEndpoint: "https://bsky.social", 349 + LastSeen: time.Now(), 350 + } 351 + return db.UpsertUser(b.db, user) 352 + } 353 + 354 + resolvedDID := ident.DID.String() 355 + handle := ident.Handle.String() 356 + pdsEndpoint := ident.PDSEndpoint() 357 + 358 + // If handle is invalid or PDS is missing, use defaults 359 + if handle == "handle.invalid" || handle == "" { 360 + handle = resolvedDID 361 + } 362 + if pdsEndpoint == "" { 363 + pdsEndpoint = "https://bsky.social" 328 364 } 329 365 330 366 // Upsert to database
+45 -17
pkg/appview/jetstream/worker.go
··· 9 9 "strings" 10 10 "time" 11 11 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 12 15 "atcr.io/pkg/appview/db" 13 16 "atcr.io/pkg/atproto" 14 17 "github.com/gorilla/websocket" ··· 31 34 wantedCollections []string 32 35 debugCollectionCount int 33 36 userCache *UserCache 34 - resolver *atproto.Resolver 37 + directory identity.Directory 35 38 eventCallback EventCallback 36 39 } 37 40 ··· 53 56 userCache: &UserCache{ 54 57 cache: make(map[string]*db.User), 55 58 }, 56 - resolver: atproto.NewResolver(), 59 + directory: identity.DefaultDirectory(), 57 60 } 58 61 } 59 62 ··· 198 201 } 199 202 200 203 // Resolve DID to get handle and PDS endpoint 201 - resolvedDID, pdsEndpoint, err := w.resolver.ResolveIdentity(ctx, did) 204 + didParsed, err := syntax.ParseDID(did) 202 205 if err != nil { 203 - fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err) 206 + fmt.Printf("WARNING: Invalid DID %s: %v (using DID as handle)\n", did, err) 204 207 // Fallback: use DID as handle 205 - resolvedDID = did 206 - pdsEndpoint = "https://bsky.social" // Default PDS endpoint as fallback 208 + user := &db.User{ 209 + DID: did, 210 + Handle: did, 211 + PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback 212 + LastSeen: time.Now(), 213 + } 214 + w.userCache.cache[did] = user 215 + return db.UpsertUser(w.db, user) 207 216 } 208 217 209 - // Get handle from DID document 210 - handle, err := w.resolver.ResolveHandleFromDID(ctx, resolvedDID) 218 + ident, err := w.directory.LookupDID(ctx, didParsed) 211 219 if err != nil { 212 - fmt.Printf("WARNING: Failed to get handle for DID %s: %v (using DID as handle)\n", resolvedDID, err) 213 - handle = resolvedDID // Fallback to DID 220 + fmt.Printf("WARNING: Failed to resolve DID %s: %v (using DID as handle)\n", did, err) 221 + // Fallback: use DID as handle 222 + user := &db.User{ 223 + DID: did, 224 + Handle: did, 225 + PDSEndpoint: "https://bsky.social", // Default PDS endpoint as fallback 226 + LastSeen: time.Now(), 227 + } 228 + w.userCache.cache[did] = user 229 + return db.UpsertUser(w.db, user) 230 + } 231 + 232 + resolvedDID := ident.DID.String() 233 + handle := ident.Handle.String() 234 + pdsEndpoint := ident.PDSEndpoint() 235 + 236 + // If handle is invalid or PDS is missing, use defaults 237 + if handle == "handle.invalid" || handle == "" { 238 + handle = resolvedDID 239 + } 240 + if pdsEndpoint == "" { 241 + pdsEndpoint = "https://bsky.social" 214 242 } 215 243 216 244 // Cache the user ··· 349 377 350 378 // CommitEvent represents a commit event (create/update/delete) 351 379 type CommitEvent struct { 352 - Rev string `json:"rev"` 353 - Operation string `json:"operation"` // "create", "update", "delete" 354 - Collection string `json:"collection"` 355 - RKey string `json:"rkey"` 356 - Record map[string]interface{} `json:"record,omitempty"` 357 - CID string `json:"cid,omitempty"` 358 - DID string `json:"-"` // Set from parent event 380 + Rev string `json:"rev"` 381 + Operation string `json:"operation"` // "create", "update", "delete" 382 + Collection string `json:"collection"` 383 + RKey string `json:"rkey"` 384 + Record map[string]any `json:"record,omitempty"` 385 + CID string `json:"cid,omitempty"` 386 + DID string `json:"-"` // Set from parent event 359 387 } 360 388 361 389 // IdentityInfo represents an identity event
+21
pkg/appview/session/session.go
··· 129 129 return sess, true 130 130 } 131 131 132 + // Extend extends a session's expiration time 133 + func (s *Store) Extend(id string, duration time.Duration) error { 134 + s.mu.Lock() 135 + defer s.mu.Unlock() 136 + 137 + sess, ok := s.sessions[id] 138 + if !ok { 139 + return fmt.Errorf("session not found: %s", id) 140 + } 141 + 142 + // Extend the expiration 143 + sess.ExpiresAt = time.Now().Add(duration) 144 + 145 + // Save to disk 146 + if err := s.save(); err != nil { 147 + fmt.Printf("Warning: Failed to save sessions to disk: %v\n", err) 148 + } 149 + 150 + return nil 151 + } 152 + 132 153 // Delete removes a session 133 154 func (s *Store) Delete(id string) { 134 155 s.mu.Lock()
+275
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> 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> 75 + </div> 76 + 77 + <!-- Existing Keys List --> 78 + <div class="keys-list"> 79 + <h3>Your API Keys</h3> 80 + <table> 81 + <thead> 82 + <tr> 83 + <th>Name</th> 84 + <th>Created</th> 85 + <th>Last Used</th> 86 + <th>Actions</th> 87 + </tr> 88 + </thead> 89 + <tbody id="keys-table"> 90 + <tr><td colspan="4">Loading...</td></tr> 91 + </tbody> 92 + </table> 93 + </div> 94 + </section> 95 + 60 96 <!-- OAuth Session Section --> 61 97 <section class="settings-section"> 62 98 <h2>OAuth Session</h2> ··· 78 114 <!-- Modal container for HTMX --> 79 115 <div id="modal"></div> 80 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 + 81 138 <script src="/static/js/app.js"></script> 139 + 140 + <script> 141 + // API Key Management JavaScript 142 + (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 + 148 + 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 + 155 + if (!resp.ok) { 156 + throw new Error('Failed to generate key'); 157 + } 158 + 159 + const data = await resp.json(); 160 + 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>'; 203 + return; 204 + } 205 + 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() 210 + : 'Never'; 211 + 212 + return ` 213 + <tr> 214 + <td>${escapeHtml(key.name)}</td> 215 + <td>${createdDate}</td> 216 + <td>${lastUsed}</td> 217 + <td><button class="btn-danger" onclick="deleteKey('${key.id}')">Revoke</button></td> 218 + </tr> 219 + `; 220 + }).join(''); 221 + } 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>'; 225 + } 226 + } 227 + 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.')) { 231 + return; 232 + } 233 + 234 + try { 235 + const resp = await fetch(`/api/keys/${id}`, { method: 'DELETE' }); 236 + if (!resp.ok) { 237 + throw new Error('Failed to delete key'); 238 + } 239 + loadKeys(); 240 + } catch (err) { 241 + alert('Error revoking key: ' + err.message); 242 + } 243 + }; 244 + 245 + // Escape HTML helper 246 + function escapeHtml(text) { 247 + const div = document.createElement('div'); 248 + div.textContent = text; 249 + return div.innerHTML; 250 + } 251 + 252 + // Load keys on page load 253 + loadKeys(); 254 + })(); 255 + </script> 256 + 257 + <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; 292 + margin: 1rem 0; 293 + border-radius: 4px; 294 + border: 1px solid #ddd; 295 + } 296 + .key-display code { 297 + word-break: break-all; 298 + font-size: 14px; 299 + display: block; 300 + margin-bottom: 1rem; 301 + } 302 + .usage-instructions { 303 + margin-top: 1rem; 304 + padding: 1rem; 305 + background: #e3f2fd; 306 + border-radius: 4px; 307 + } 308 + .usage-instructions h4 { 309 + margin-top: 0; 310 + } 311 + .usage-instructions pre { 312 + background: #263238; 313 + color: #aed581; 314 + padding: 1rem; 315 + border-radius: 4px; 316 + overflow-x: auto; 317 + margin: 0.5rem 0 0 0; 318 + } 319 + .usage-instructions code { 320 + font-family: monospace; 321 + } 322 + 323 + /* API Keys Section Styles */ 324 + .api-keys-section table { 325 + width: 100%; 326 + border-collapse: collapse; 327 + margin-top: 1rem; 328 + } 329 + .api-keys-section th, 330 + .api-keys-section td { 331 + padding: 0.75rem; 332 + text-align: left; 333 + border-bottom: 1px solid #ddd; 334 + } 335 + .api-keys-section th { 336 + background: #f5f5f5; 337 + font-weight: bold; 338 + } 339 + .api-keys-section .btn-danger { 340 + background: #dc3545; 341 + color: white; 342 + border: none; 343 + padding: 0.5rem 1rem; 344 + border-radius: 4px; 345 + cursor: pointer; 346 + } 347 + .api-keys-section .btn-danger:hover { 348 + background: #c82333; 349 + } 350 + .generate-key { 351 + margin: 1rem 0; 352 + padding: 1rem; 353 + background: #f8f9fa; 354 + border-radius: 4px; 355 + } 356 + </style> 82 357 </body> 83 358 </html> 84 359 {{ end }}
+81 -60
pkg/atproto/client.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 - "crypto/ecdsa" 7 6 "encoding/json" 8 7 "fmt" 9 8 "io" 10 9 "net/http" 10 + "strings" 11 + 12 + "github.com/bluesky-social/indigo/atproto/client" 11 13 ) 12 14 13 15 // Client wraps ATProto operations for the registry 14 16 type Client struct { 15 - pdsEndpoint string 16 - did string 17 - accessToken string 18 - httpClient *http.Client 19 - useDPoP bool // true if using DPoP-bound tokens (OAuth) 17 + pdsEndpoint string 18 + did string 19 + accessToken string // For Basic Auth only 20 + httpClient *http.Client 21 + useIndigoClient bool // true if using indigo's OAuth client (handles auth automatically) 22 + indigoClient *client.APIClient // indigo's API client for OAuth requests 20 23 } 21 24 22 - // NewClient creates a new ATProto client for Basic Auth tokens 25 + // NewClient creates a new ATProto client for Basic Auth tokens (app passwords) 23 26 func NewClient(pdsEndpoint, did, accessToken string) *Client { 24 27 return &Client{ 25 28 pdsEndpoint: pdsEndpoint, 26 29 did: did, 27 30 accessToken: accessToken, 28 31 httpClient: &http.Client{}, 29 - useDPoP: false, // Basic Auth uses Bearer tokens 30 32 } 31 33 } 32 34 33 - // NewClientWithDPoP creates a new ATProto client with DPoP support 34 - // This is required for OAuth tokens 35 - func NewClientWithDPoP(pdsEndpoint, did, accessToken string, dpopKey *ecdsa.PrivateKey, transport http.RoundTripper) *Client { 35 + // NewClientWithIndigoClient creates an ATProto client using indigo's API client 36 + // This uses indigo's native XRPC methods with automatic DPoP handling 37 + func NewClientWithIndigoClient(pdsEndpoint, did string, indigoClient *client.APIClient) *Client { 36 38 return &Client{ 37 - pdsEndpoint: pdsEndpoint, 38 - did: did, 39 - accessToken: accessToken, 40 - httpClient: &http.Client{ 41 - Transport: transport, 42 - }, 43 - useDPoP: true, // OAuth uses DPoP tokens 39 + pdsEndpoint: pdsEndpoint, 40 + did: did, 41 + useIndigoClient: true, 42 + indigoClient: indigoClient, 43 + httpClient: indigoClient.Client, // Keep for any fallback cases 44 44 } 45 45 } 46 46 47 - // NewClientWithHTTPClient creates a new ATProto client with a pre-configured HTTP client 48 - // This is useful when using indigo's OAuth session which provides a DPoP-configured client 49 - // The access token will be used for Authorization headers, while the HTTP client 50 - // handles transport-level concerns (like DPoP proofs) 51 - func NewClientWithHTTPClient(pdsEndpoint, did, accessToken string, httpClient *http.Client) *Client { 52 - return &Client{ 53 - pdsEndpoint: pdsEndpoint, 54 - did: did, 55 - accessToken: accessToken, 56 - httpClient: httpClient, 57 - useDPoP: true, // Assume DPoP when using custom client 58 - } 59 - } 60 - 61 - // authHeader returns the appropriate Authorization header value 62 - func (c *Client) authHeader() string { 63 - if c.useDPoP { 64 - return "DPoP " + c.accessToken 65 - } 66 - return "Bearer " + c.accessToken 67 - } 68 - 69 47 // Record represents a generic ATProto record 70 48 type Record struct { 71 49 URI string `json:"uri"` ··· 75 53 76 54 // PutRecord stores a record in the ATProto repository 77 55 func (c *Client) PutRecord(ctx context.Context, collection, rkey string, record any) (*Record, error) { 78 - // Construct the record URI 79 - // Format: at://<did>/<collection>/<rkey> 80 - 81 56 payload := map[string]any{ 82 57 "repo": c.did, 83 58 "collection": collection, ··· 85 60 "record": record, 86 61 } 87 62 63 + // Use indigo API client (OAuth with DPoP) 64 + if c.useIndigoClient && c.indigoClient != nil { 65 + var result Record 66 + err := c.indigoClient.Post(ctx, "com.atproto.repo.putRecord", payload, &result) 67 + if err != nil { 68 + return nil, fmt.Errorf("putRecord failed: %w", err) 69 + } 70 + return &result, nil 71 + } 72 + 73 + // Basic Auth (app passwords) 88 74 body, err := json.Marshal(payload) 89 75 if err != nil { 90 76 return nil, fmt.Errorf("failed to marshal record: %w", err) ··· 96 82 return nil, err 97 83 } 98 84 99 - req.Header.Set("Authorization", c.authHeader()) 85 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 100 86 req.Header.Set("Content-Type", "application/json") 101 87 102 88 resp, err := c.httpClient.Do(req) ··· 120 106 121 107 // GetRecord retrieves a record from the ATProto repository 122 108 func (c *Client) GetRecord(ctx context.Context, collection, rkey string) (*Record, error) { 109 + // Use indigo API client (OAuth with DPoP) 110 + if c.useIndigoClient && c.indigoClient != nil { 111 + params := map[string]any{ 112 + "repo": c.did, 113 + "collection": collection, 114 + "rkey": rkey, 115 + } 116 + 117 + var result Record 118 + err := c.indigoClient.Get(ctx, "com.atproto.repo.getRecord", params, &result) 119 + if err != nil { 120 + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") { 121 + return nil, fmt.Errorf("record not found") 122 + } 123 + return nil, fmt.Errorf("getRecord failed: %w", err) 124 + } 125 + return &result, nil 126 + } 127 + 128 + // Basic Auth (app passwords) 123 129 url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 124 130 c.pdsEndpoint, c.did, collection, rkey) 125 131 ··· 128 134 return nil, err 129 135 } 130 136 131 - req.Header.Set("Authorization", c.authHeader()) 137 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 132 138 133 139 resp, err := c.httpClient.Do(req) 134 140 if err != nil { ··· 172 178 return err 173 179 } 174 180 175 - req.Header.Set("Authorization", c.authHeader()) 181 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 176 182 req.Header.Set("Content-Type", "application/json") 177 183 178 184 resp, err := c.httpClient.Do(req) ··· 199 205 return nil, err 200 206 } 201 207 202 - req.Header.Set("Authorization", c.authHeader()) 208 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 203 209 204 210 resp, err := c.httpClient.Do(req) 205 211 if err != nil { ··· 238 244 239 245 // UploadBlob uploads binary data to the PDS and returns a blob reference 240 246 func (c *Client) UploadBlob(ctx context.Context, data []byte, mimeType string) (*ATProtoBlobRef, error) { 241 - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint) 247 + // Use indigo API client (OAuth with DPoP) 248 + if c.useIndigoClient && c.indigoClient != nil { 249 + var result struct { 250 + Blob ATProtoBlobRef `json:"blob"` 251 + } 242 252 253 + err := c.indigoClient.LexDo(ctx, 254 + "POST", 255 + mimeType, 256 + "com.atproto.repo.uploadBlob", 257 + nil, 258 + data, 259 + &result, 260 + ) 261 + if err != nil { 262 + return nil, fmt.Errorf("uploadBlob failed: %w", err) 263 + } 264 + 265 + return &result.Blob, nil 266 + } 267 + 268 + // Basic Auth (app passwords) 269 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", c.pdsEndpoint) 243 270 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) 244 271 if err != nil { 245 272 return nil, err 246 273 } 247 274 248 - // Only set Authorization header if we have an access token 249 - if c.accessToken != "" { 250 - authHeader := c.authHeader() 251 - fmt.Printf("DEBUG [atproto/client]: UploadBlob Authorization header: %q (useDPoP=%v, token_length=%d)\n", authHeader, c.useDPoP, len(c.accessToken)) 252 - req.Header.Set("Authorization", authHeader) 253 - } else { 254 - fmt.Printf("DEBUG [atproto/client]: UploadBlob: No access token available, sending unauthenticated request\n") 255 - return nil, fmt.Errorf("no access token available for authenticated PDS operation - please complete OAuth flow at: http://127.0.0.1:5000/auth/oauth/authorize?handle=<your-handle>") 256 - } 275 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 257 276 req.Header.Set("Content-Type", mimeType) 258 277 259 278 resp, err := c.httpClient.Do(req) ··· 288 307 } 289 308 290 309 // Note: getBlob may not require auth for public repos, but we include it anyway 291 - req.Header.Set("Authorization", c.authHeader()) 310 + if c.accessToken != "" { 311 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 312 + } 292 313 293 314 resp, err := c.httpClient.Do(req) 294 315 if err != nil { ··· 346 367 // This endpoint typically doesn't require auth for public data 347 368 // but we include it if available 348 369 if c.accessToken != "" { 349 - req.Header.Set("Authorization", c.authHeader()) 370 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 350 371 } 351 372 352 373 resp, err := c.httpClient.Do(req) ··· 388 409 389 410 // This endpoint typically doesn't require auth for public records 390 411 if c.accessToken != "" { 391 - req.Header.Set("Authorization", c.authHeader()) 412 + req.Header.Set("Authorization", "Bearer "+c.accessToken) 392 413 } 393 414 394 415 resp, err := c.httpClient.Do(req)
-243
pkg/atproto/resolver.go
··· 1 - package atproto 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "net" 9 - "net/http" 10 - "strings" 11 - ) 12 - 13 - // Resolver handles DID/handle resolution for ATProto 14 - type Resolver struct { 15 - httpClient *http.Client 16 - } 17 - 18 - // NewResolver creates a new DID/handle resolver 19 - func NewResolver() *Resolver { 20 - return &Resolver{ 21 - httpClient: &http.Client{}, 22 - } 23 - } 24 - 25 - // ResolveIdentity resolves a handle or DID to a DID and PDS endpoint 26 - // Input can be: 27 - // - Handle: "alice.bsky.social" or "alice" 28 - // - DID: "did:plc:xyz123abc" 29 - func (r *Resolver) ResolveIdentity(ctx context.Context, identity string) (did string, pdsEndpoint string, err error) { 30 - // Check if it's already a DID 31 - if strings.HasPrefix(identity, "did:") { 32 - did = identity 33 - pdsEndpoint, err = r.ResolvePDS(ctx, did) 34 - return did, pdsEndpoint, err 35 - } 36 - 37 - // Otherwise, resolve handle to DID 38 - did, err = r.ResolveHandle(ctx, identity) 39 - if err != nil { 40 - return "", "", fmt.Errorf("failed to resolve handle %s: %w", identity, err) 41 - } 42 - 43 - // Then resolve DID to PDS 44 - pdsEndpoint, err = r.ResolvePDS(ctx, did) 45 - if err != nil { 46 - return "", "", fmt.Errorf("failed to resolve PDS for DID %s: %w", did, err) 47 - } 48 - 49 - return did, pdsEndpoint, nil 50 - } 51 - 52 - // ResolveHandle resolves a handle to a DID using DNS TXT records or .well-known 53 - func (r *Resolver) ResolveHandle(ctx context.Context, handle string) (string, error) { 54 - // Normalize handle 55 - if !strings.Contains(handle, ".") { 56 - // Default to .bsky.social if no domain provided 57 - handle = handle + ".bsky.social" 58 - } 59 - 60 - // Try DNS TXT record first (faster) 61 - if did, err := r.resolveHandleViaDNS(handle); err == nil && did != "" { 62 - return did, nil 63 - } 64 - 65 - // Fall back to HTTPS .well-known method 66 - url := fmt.Sprintf("https://%s/.well-known/atproto-did", handle) 67 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 68 - if err != nil { 69 - return "", err 70 - } 71 - 72 - resp, err := r.httpClient.Do(req) 73 - if err != nil { 74 - return "", fmt.Errorf("failed to fetch .well-known: %w", err) 75 - } 76 - defer resp.Body.Close() 77 - 78 - if resp.StatusCode == http.StatusOK { 79 - body, err := io.ReadAll(resp.Body) 80 - if err != nil { 81 - return "", err 82 - } 83 - did := strings.TrimSpace(string(body)) 84 - if strings.HasPrefix(did, "did:") { 85 - return did, nil 86 - } 87 - } 88 - 89 - return "", fmt.Errorf("could not resolve handle %s to DID", handle) 90 - } 91 - 92 - // resolveHandleViaDNS attempts to resolve handle via DNS TXT record at _atproto.<handle> 93 - func (r *Resolver) resolveHandleViaDNS(handle string) (string, error) { 94 - txtRecords, err := net.LookupTXT("_atproto." + handle) 95 - if err != nil { 96 - return "", err 97 - } 98 - 99 - // Look for a TXT record that starts with "did=" 100 - for _, record := range txtRecords { 101 - if strings.HasPrefix(record, "did=") { 102 - did := strings.TrimPrefix(record, "did=") 103 - if strings.HasPrefix(did, "did:") { 104 - return did, nil 105 - } 106 - } 107 - } 108 - 109 - return "", fmt.Errorf("no valid DID found in DNS TXT records") 110 - } 111 - 112 - // DIDDocument represents a simplified ATProto DID document 113 - type DIDDocument struct { 114 - ID string `json:"id"` 115 - AlsoKnownAs []string `json:"alsoKnownAs,omitempty"` 116 - Service []struct { 117 - ID string `json:"id"` 118 - Type string `json:"type"` 119 - ServiceEndpoint string `json:"serviceEndpoint"` 120 - } `json:"service"` 121 - } 122 - 123 - // ResolvePDS resolves a DID to its PDS endpoint 124 - func (r *Resolver) ResolvePDS(ctx context.Context, did string) (string, error) { 125 - if !strings.HasPrefix(did, "did:") { 126 - return "", fmt.Errorf("invalid DID format: %s", did) 127 - } 128 - 129 - // Parse DID method 130 - parts := strings.Split(did, ":") 131 - if len(parts) < 3 { 132 - return "", fmt.Errorf("invalid DID format: %s", did) 133 - } 134 - 135 - method := parts[1] 136 - 137 - var resolverURL string 138 - switch method { 139 - case "plc": 140 - // Use PLC directory 141 - resolverURL = fmt.Sprintf("https://plc.directory/%s", did) 142 - case "web": 143 - // For did:web, convert to HTTPS URL 144 - domain := parts[2] 145 - resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 146 - default: 147 - return "", fmt.Errorf("unsupported DID method: %s", method) 148 - } 149 - 150 - req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil) 151 - if err != nil { 152 - return "", err 153 - } 154 - 155 - resp, err := r.httpClient.Do(req) 156 - if err != nil { 157 - return "", fmt.Errorf("failed to fetch DID document: %w", err) 158 - } 159 - defer resp.Body.Close() 160 - 161 - if resp.StatusCode != http.StatusOK { 162 - return "", fmt.Errorf("DID resolution failed with status %d", resp.StatusCode) 163 - } 164 - 165 - var didDoc DIDDocument 166 - if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { 167 - return "", fmt.Errorf("failed to parse DID document: %w", err) 168 - } 169 - 170 - // Find PDS service endpoint 171 - for _, service := range didDoc.Service { 172 - if service.Type == "AtprotoPersonalDataServer" { 173 - return service.ServiceEndpoint, nil 174 - } 175 - } 176 - 177 - return "", fmt.Errorf("no PDS endpoint found in DID document") 178 - } 179 - 180 - // ResolveDIDDocument fetches the full DID document for a DID 181 - func (r *Resolver) ResolveDIDDocument(ctx context.Context, did string) (*DIDDocument, error) { 182 - if !strings.HasPrefix(did, "did:") { 183 - return nil, fmt.Errorf("invalid DID format: %s", did) 184 - } 185 - 186 - parts := strings.Split(did, ":") 187 - if len(parts) < 3 { 188 - return nil, fmt.Errorf("invalid DID format: %s", did) 189 - } 190 - 191 - method := parts[1] 192 - 193 - var resolverURL string 194 - switch method { 195 - case "plc": 196 - resolverURL = fmt.Sprintf("https://plc.directory/%s", did) 197 - case "web": 198 - domain := parts[2] 199 - resolverURL = fmt.Sprintf("https://%s/.well-known/did.json", domain) 200 - default: 201 - return nil, fmt.Errorf("unsupported DID method: %s", method) 202 - } 203 - 204 - req, err := http.NewRequestWithContext(ctx, "GET", resolverURL, nil) 205 - if err != nil { 206 - return nil, err 207 - } 208 - 209 - resp, err := r.httpClient.Do(req) 210 - if err != nil { 211 - return nil, fmt.Errorf("failed to fetch DID document: %w", err) 212 - } 213 - defer resp.Body.Close() 214 - 215 - if resp.StatusCode != http.StatusOK { 216 - return nil, fmt.Errorf("DID resolution failed with status %d", resp.StatusCode) 217 - } 218 - 219 - var didDoc DIDDocument 220 - if err := json.NewDecoder(resp.Body).Decode(&didDoc); err != nil { 221 - return nil, fmt.Errorf("failed to parse DID document: %w", err) 222 - } 223 - 224 - return &didDoc, nil 225 - } 226 - 227 - // ResolveHandle extracts the handle from a DID's alsoKnownAs field 228 - func (r *Resolver) ResolveHandleFromDID(ctx context.Context, did string) (string, error) { 229 - didDoc, err := r.ResolveDIDDocument(ctx, did) 230 - if err != nil { 231 - return "", err 232 - } 233 - 234 - // Look for handle in alsoKnownAs (format: "at://handle.bsky.social") 235 - for _, aka := range didDoc.AlsoKnownAs { 236 - if strings.HasPrefix(aka, "at://") { 237 - handle := strings.TrimPrefix(aka, "at://") 238 - return handle, nil 239 - } 240 - } 241 - 242 - return "", fmt.Errorf("no handle found in DID document") 243 - }
+28 -5
pkg/auth/atproto/session.go
··· 12 12 "sync" 13 13 "time" 14 14 15 - atprotoclient "atcr.io/pkg/atproto" 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 16 17 ) 17 18 18 19 // CachedSession represents a cached session ··· 25 26 26 27 // SessionValidator validates ATProto credentials 27 28 type SessionValidator struct { 28 - resolver *atprotoclient.Resolver 29 + directory identity.Directory 29 30 httpClient *http.Client 30 31 cache map[string]*CachedSession 31 32 cacheMu sync.RWMutex ··· 34 35 // NewSessionValidator creates a new ATProto session validator 35 36 func NewSessionValidator() *SessionValidator { 36 37 return &SessionValidator{ 37 - resolver: atprotoclient.NewResolver(), 38 + directory: identity.DefaultDirectory(), 38 39 httpClient: &http.Client{}, 39 40 cache: make(map[string]*CachedSession), 40 41 } ··· 86 87 // Returns the user's DID and PDS endpoint if valid 87 88 func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) { 88 89 // Resolve identifier (handle or DID) to PDS endpoint 89 - resolvedDID, pds, err := v.resolver.ResolveIdentity(ctx, identifier) 90 + atID, err := syntax.ParseAtIdentifier(identifier) 91 + if err != nil { 92 + return "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err) 93 + } 94 + 95 + ident, err := v.directory.Lookup(ctx, *atID) 90 96 if err != nil { 91 97 return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) 92 98 } 93 99 100 + resolvedDID := ident.DID.String() 101 + pds := ident.PDSEndpoint() 102 + if pds == "" { 103 + return "", "", fmt.Errorf("no PDS endpoint found for %q", identifier) 104 + } 105 + 94 106 fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds) 95 107 96 108 // Create session with the PDS ··· 119 131 fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier) 120 132 121 133 // Resolve identifier to PDS endpoint 122 - did, pds, err := v.resolver.ResolveIdentity(ctx, identifier) 134 + atID, err := syntax.ParseAtIdentifier(identifier) 135 + if err != nil { 136 + return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err) 137 + } 138 + 139 + ident, err := v.directory.Lookup(ctx, *atID) 123 140 if err != nil { 124 141 return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) 142 + } 143 + 144 + did = ident.DID.String() 145 + pds := ident.PDSEndpoint() 146 + if pds == "" { 147 + return "", "", "", fmt.Errorf("no PDS endpoint found for %q", identifier) 125 148 } 126 149 127 150 // Create session
+14 -3
pkg/auth/atproto/validator.go
··· 7 7 "io" 8 8 "net/http" 9 9 10 - mainAtproto "atcr.io/pkg/atproto" 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 11 12 ) 12 13 13 14 // TokenValidator validates ATProto OAuth access tokens ··· 90 91 // dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer 91 92 func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) { 92 93 // Resolve handle to PDS endpoint 93 - resolver := mainAtproto.NewResolver() 94 - _, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle) 94 + directory := identity.DefaultDirectory() 95 + atID, err := syntax.ParseAtIdentifier(handle) 96 + if err != nil { 97 + return nil, fmt.Errorf("invalid identifier %q: %w", handle, err) 98 + } 99 + 100 + ident, err := directory.Lookup(ctx, *atID) 95 101 if err != nil { 96 102 return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) 103 + } 104 + 105 + pdsEndpoint := ident.PDSEndpoint() 106 + if pdsEndpoint == "" { 107 + return nil, fmt.Errorf("no PDS endpoint found for %q", handle) 97 108 } 98 109 99 110 // Validate token against the PDS
-116
pkg/auth/exchange/handler.go
··· 1 - package exchange 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "strings" 8 - 9 - "atcr.io/pkg/auth" 10 - "atcr.io/pkg/auth/session" 11 - "atcr.io/pkg/auth/token" 12 - ) 13 - 14 - // Handler handles /auth/exchange requests (session token -> registry JWT) 15 - type Handler struct { 16 - issuer *token.Issuer 17 - sessionManager *session.Manager 18 - } 19 - 20 - // NewHandler creates a new exchange handler 21 - func NewHandler(issuer *token.Issuer, sessionManager *session.Manager) *Handler { 22 - return &Handler{ 23 - issuer: issuer, 24 - sessionManager: sessionManager, 25 - } 26 - } 27 - 28 - // ExchangeRequest represents the request to exchange a session token for registry JWT 29 - type ExchangeRequest struct { 30 - Scope []string `json:"scope"` // Requested Docker scopes 31 - } 32 - 33 - // ExchangeResponse represents the response from /auth/exchange 34 - type ExchangeResponse struct { 35 - Token string `json:"token"` 36 - AccessToken string `json:"access_token"` 37 - ExpiresIn int `json:"expires_in"` 38 - } 39 - 40 - // ServeHTTP handles the exchange request 41 - func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 42 - if r.Method != http.MethodPost { 43 - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 44 - return 45 - } 46 - 47 - // Extract session token from Authorization header 48 - authHeader := r.Header.Get("Authorization") 49 - if authHeader == "" { 50 - http.Error(w, "authorization header required", http.StatusUnauthorized) 51 - return 52 - } 53 - 54 - // Parse Bearer token 55 - parts := strings.SplitN(authHeader, " ", 2) 56 - if len(parts) != 2 || parts[0] != "Bearer" { 57 - http.Error(w, "invalid authorization header format", http.StatusUnauthorized) 58 - return 59 - } 60 - sessionToken := parts[1] 61 - 62 - // Validate session token 63 - sessionClaims, err := h.sessionManager.Validate(sessionToken) 64 - if err != nil { 65 - fmt.Printf("DEBUG [exchange]: session validation failed: %v\n", err) 66 - http.Error(w, fmt.Sprintf("invalid session token: %v", err), http.StatusUnauthorized) 67 - return 68 - } 69 - 70 - fmt.Printf("DEBUG [exchange]: session validated for DID=%s, handle=%s\n", sessionClaims.DID, sessionClaims.Handle) 71 - 72 - // Parse request body for scopes 73 - var req ExchangeRequest 74 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 75 - http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) 76 - return 77 - } 78 - 79 - // Parse and validate scopes 80 - access, err := auth.ParseScope(req.Scope) 81 - if err != nil { 82 - http.Error(w, fmt.Sprintf("invalid scope: %v", err), http.StatusBadRequest) 83 - return 84 - } 85 - 86 - // Validate access permissions 87 - if err := auth.ValidateAccess(sessionClaims.DID, sessionClaims.Handle, access); err != nil { 88 - http.Error(w, fmt.Sprintf("access denied: %v", err), http.StatusForbidden) 89 - return 90 - } 91 - 92 - // Issue registry JWT token 93 - tokenString, err := h.issuer.Issue(sessionClaims.DID, access) 94 - if err != nil { 95 - http.Error(w, fmt.Sprintf("failed to issue token: %v", err), http.StatusInternalServerError) 96 - return 97 - } 98 - 99 - // Return response 100 - resp := ExchangeResponse{ 101 - Token: tokenString, 102 - AccessToken: tokenString, 103 - ExpiresIn: int(h.issuer.Expiration().Seconds()), 104 - } 105 - 106 - w.Header().Set("Content-Type", "application/json") 107 - if err := json.NewEncoder(w).Encode(resp); err != nil { 108 - http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) 109 - return 110 - } 111 - } 112 - 113 - // RegisterRoutes registers the exchange handler with the provided mux 114 - func (h *Handler) RegisterRoutes(mux *http.ServeMux) { 115 - mux.Handle("/auth/exchange", h) 116 - }
+25
pkg/auth/oauth/browser.go
··· 1 + package oauth 2 + 3 + import ( 4 + "fmt" 5 + "os/exec" 6 + "runtime" 7 + ) 8 + 9 + // OpenBrowser opens the default browser to the given URL 10 + func OpenBrowser(url string) error { 11 + var cmd *exec.Cmd 12 + 13 + switch runtime.GOOS { 14 + case "darwin": 15 + cmd = exec.Command("open", url) 16 + case "linux": 17 + cmd = exec.Command("xdg-open", url) 18 + case "windows": 19 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 20 + default: 21 + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) 22 + } 23 + 24 + return cmd.Start() 25 + }
+3 -2
pkg/auth/oauth/client.go
··· 8 8 9 9 "atcr.io/pkg/atproto" 10 10 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/identity" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 12 13 ) 13 14 ··· 15 16 type App struct { 16 17 clientApp *oauth.ClientApp 17 18 baseURL string 18 - resolver *atproto.Resolver 19 + directory identity.Directory 19 20 } 20 21 21 22 // NewApp creates a new OAuth app for ATCR ··· 26 27 return &App{ 27 28 clientApp: clientApp, 28 29 baseURL: baseURL, 29 - resolver: atproto.NewResolver(), 30 + directory: identity.DefaultDirectory(), 30 31 }, nil 31 32 } 32 33
+187
pkg/auth/oauth/interactive.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + "sync" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 12 + ) 13 + 14 + // InteractiveResult contains the result of an interactive OAuth flow 15 + type InteractiveResult struct { 16 + SessionData *oauth.ClientSessionData 17 + Session *oauth.ClientSession 18 + App *App 19 + } 20 + 21 + // RunInteractiveFlow runs an interactive OAuth flow for CLI tools 22 + // This is a simplified wrapper around indigo's OAuth flow 23 + func RunInteractiveFlow( 24 + ctx context.Context, 25 + baseURL string, 26 + handle string, 27 + scopes []string, 28 + onAuthURL func(string) error, 29 + ) (*InteractiveResult, error) { 30 + // Create temporary file store for this flow 31 + store, err := NewFileStore("/tmp/atcr-oauth-temp.json") 32 + if err != nil { 33 + return nil, fmt.Errorf("failed to create OAuth store: %w", err) 34 + } 35 + 36 + // Create OAuth app 37 + app, err := NewApp(baseURL, store) 38 + if err != nil { 39 + return nil, fmt.Errorf("failed to create OAuth app: %w", err) 40 + } 41 + 42 + // Set custom scopes if provided 43 + if len(scopes) > 0 { 44 + // Note: indigo's ClientApp doesn't expose SetScopes, so we need to use default scopes 45 + // This is a limitation of the current implementation 46 + // TODO: Enhance if custom scopes are needed 47 + } 48 + 49 + // Start auth flow 50 + authURL, err := app.StartAuthFlow(ctx, handle) 51 + if err != nil { 52 + return nil, fmt.Errorf("failed to start auth flow: %w", err) 53 + } 54 + 55 + // Call the callback to display the auth URL 56 + if err := onAuthURL(authURL); err != nil { 57 + return nil, fmt.Errorf("auth URL callback failed: %w", err) 58 + } 59 + 60 + // Wait for OAuth callback 61 + // The callback will be handled by the http.HandleFunc registered by the caller 62 + // We need to wait for ProcessCallback to be called 63 + // This is a bit awkward, but matches the old pattern 64 + 65 + // Setup a channel to receive callback params 66 + callbackChan := make(chan url.Values, 1) 67 + var setupOnce sync.Once 68 + 69 + // Return a function that the caller can use to process the callback 70 + // This is called from the HTTP handler 71 + processCallback := func(params url.Values) (*oauth.ClientSessionData, error) { 72 + setupOnce.Do(func() { 73 + callbackChan <- params 74 + }) 75 + sessionData, err := app.ProcessCallback(ctx, params) 76 + if err != nil { 77 + return nil, fmt.Errorf("failed to process callback: %w", err) 78 + } 79 + return sessionData, nil 80 + } 81 + 82 + // Wait for callback with timeout 83 + select { 84 + case params := <-callbackChan: 85 + sessionData, err := processCallback(params) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + // Resume session to get ClientSession 91 + session, err := app.ResumeSession(ctx, sessionData.AccountDID, sessionData.SessionID) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to resume session: %w", err) 94 + } 95 + 96 + return &InteractiveResult{ 97 + SessionData: sessionData, 98 + Session: session, 99 + App: app, 100 + }, nil 101 + case <-time.After(5 * time.Minute): 102 + return nil, fmt.Errorf("OAuth flow timed out after 5 minutes") 103 + } 104 + } 105 + 106 + // InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling 107 + // This version allows the caller to register the callback handler before starting the flow 108 + func InteractiveFlowWithCallback( 109 + ctx context.Context, 110 + baseURL string, 111 + handle string, 112 + scopes []string, 113 + registerCallback func(handler http.HandlerFunc) error, 114 + displayAuthURL func(string) error, 115 + ) (*InteractiveResult, error) { 116 + // Create temporary file store for this flow 117 + store, err := NewFileStore("/tmp/atcr-oauth-temp.json") 118 + if err != nil { 119 + return nil, fmt.Errorf("failed to create OAuth store: %w", err) 120 + } 121 + 122 + // Create OAuth app 123 + app, err := NewApp(baseURL, store) 124 + if err != nil { 125 + return nil, fmt.Errorf("failed to create OAuth app: %w", err) 126 + } 127 + 128 + // Channel to receive callback result 129 + resultChan := make(chan *InteractiveResult, 1) 130 + errorChan := make(chan error, 1) 131 + 132 + // Create callback handler 133 + callbackHandler := func(w http.ResponseWriter, r *http.Request) { 134 + // Process callback 135 + sessionData, err := app.ProcessCallback(r.Context(), r.URL.Query()) 136 + if err != nil { 137 + errorChan <- fmt.Errorf("failed to process callback: %w", err) 138 + http.Error(w, "OAuth callback failed", http.StatusInternalServerError) 139 + return 140 + } 141 + 142 + // Resume session 143 + session, err := app.ResumeSession(r.Context(), sessionData.AccountDID, sessionData.SessionID) 144 + if err != nil { 145 + errorChan <- fmt.Errorf("failed to resume session: %w", err) 146 + http.Error(w, "Failed to resume session", http.StatusInternalServerError) 147 + return 148 + } 149 + 150 + // Send result 151 + resultChan <- &InteractiveResult{ 152 + SessionData: sessionData, 153 + Session: session, 154 + App: app, 155 + } 156 + 157 + // Return success to browser 158 + w.Header().Set("Content-Type", "text/html") 159 + fmt.Fprintf(w, "<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>") 160 + } 161 + 162 + // Register callback handler 163 + if err := registerCallback(callbackHandler); err != nil { 164 + return nil, fmt.Errorf("failed to register callback: %w", err) 165 + } 166 + 167 + // Start auth flow 168 + authURL, err := app.StartAuthFlow(ctx, handle) 169 + if err != nil { 170 + return nil, fmt.Errorf("failed to start auth flow: %w", err) 171 + } 172 + 173 + // Display auth URL 174 + if err := displayAuthURL(authURL); err != nil { 175 + return nil, fmt.Errorf("failed to display auth URL: %w", err) 176 + } 177 + 178 + // Wait for callback result 179 + select { 180 + case result := <-resultChan: 181 + return result, nil 182 + case err := <-errorChan: 183 + return nil, err 184 + case <-time.After(5 * time.Minute): 185 + return nil, fmt.Errorf("OAuth flow timed out after 5 minutes") 186 + } 187 + }
+40 -52
pkg/auth/oauth/server.go
··· 7 7 "net/http" 8 8 "time" 9 9 10 - "atcr.io/pkg/auth/session" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 11 ) 12 12 13 13 // UISessionStore is the interface for UI session management ··· 18 18 // Server handles OAuth authorization for the AppView 19 19 type Server struct { 20 20 app *App 21 - sessionManager *session.Manager 22 21 refresher *Refresher 23 22 uiSessionStore UISessionStore 24 23 } 25 24 26 25 // NewServer creates a new OAuth server 27 - func NewServer(app *App, sessionManager *session.Manager) *Server { 26 + func NewServer(app *App) *Server { 28 27 return &Server{ 29 - app: app, 30 - sessionManager: sessionManager, 28 + app: app, 31 29 } 32 30 } 33 31 ··· 104 102 fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did) 105 103 } 106 104 107 - // We need to get the handle for the session token 105 + // We need to get the handle for UI sessions and settings redirect 108 106 // Resolve DID to handle using our resolver 109 107 handle, err := s.resolveHandle(r.Context(), did) 110 108 if err != nil { ··· 112 110 handle = did // Fallback to DID if resolution fails 113 111 } 114 112 115 - // Create session token for credential helper 116 - sessionToken, err := s.sessionManager.Create(did, handle) 117 - if err != nil { 118 - s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err)) 119 - return 120 - } 121 - 122 113 // Check if this is a UI login (has oauth_return_to cookie) 123 114 if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil { 124 - // Create UI session 125 - uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour) 115 + // Create UI session (30 days to match OAuth refresh token lifetime) 116 + uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 30*24*time.Hour) 126 117 if err != nil { 127 118 s.renderError(w, fmt.Sprintf("Failed to create UI session: %v", err)) 128 119 return ··· 133 124 Name: "atcr_session", 134 125 Value: uiSessionID, 135 126 Path: "/", 136 - MaxAge: 86400, // 24 hours 127 + MaxAge: 30 * 86400, // 30 days 137 128 HttpOnly: true, 138 129 Secure: true, 139 130 SameSite: http.SameSiteLaxMode, ··· 157 148 return 158 149 } 159 150 160 - // Render success page with session token (for credential helper) 161 - s.renderSuccess(w, sessionToken, handle) 151 + // Non-UI flow: redirect to settings to get API key 152 + s.renderRedirectToSettings(w, handle) 162 153 } 163 154 164 155 // resolveHandle attempts to resolve a DID to a handle 165 - // This is a best-effort helper - we use the resolver to look up the handle 166 - func (s *Server) resolveHandle(ctx context.Context, did string) (string, error) { 167 - // Parse the DID document to get the handle 168 - // Note: This is a simple implementation - in production we might want to cache this 169 - doc, err := s.app.resolver.ResolveDIDDocument(ctx, did) 156 + // This is a best-effort helper - we use the directory to look up the handle 157 + func (s *Server) resolveHandle(ctx context.Context, didStr string) (string, error) { 158 + // Parse DID 159 + did, err := syntax.ParseDID(didStr) 170 160 if err != nil { 171 - return "", fmt.Errorf("failed to resolve DID document: %w", err) 161 + return "", fmt.Errorf("invalid DID: %w", err) 172 162 } 173 163 174 - // Try to find a handle in the alsoKnownAs field 175 - for _, aka := range doc.AlsoKnownAs { 176 - if len(aka) > 5 && aka[:5] == "at://" { 177 - return aka[5:], nil 178 - } 164 + // Look up identity 165 + ident, err := s.app.directory.LookupDID(ctx, did) 166 + if err != nil { 167 + return "", fmt.Errorf("failed to lookup DID: %w", err) 179 168 } 180 169 181 - return "", fmt.Errorf("no handle found in DID document") 170 + // Return handle (may be handle.invalid if verification failed) 171 + return ident.Handle.String(), nil 182 172 } 183 173 184 - // renderSuccess renders the success page 185 - func (s *Server) renderSuccess(w http.ResponseWriter, sessionToken, handle string) { 186 - tmpl := template.Must(template.New("success").Parse(successTemplate)) 174 + // renderRedirectToSettings redirects to the settings page to generate an API key 175 + func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) { 176 + tmpl := template.Must(template.New("redirect").Parse(redirectToSettingsTemplate)) 187 177 data := struct { 188 - SessionToken string 189 - Handle string 178 + Handle string 190 179 }{ 191 - SessionToken: sessionToken, 192 - Handle: handle, 180 + Handle: handle, 193 181 } 194 182 195 183 w.Header().Set("Content-Type", "text/html; charset=utf-8") ··· 216 204 217 205 // HTML templates 218 206 219 - const successTemplate = ` 207 + const redirectToSettingsTemplate = ` 220 208 <!DOCTYPE html> 221 209 <html> 222 210 <head> 223 211 <title>Authorization Successful - ATCR</title> 212 + <meta http-equiv="refresh" content="3;url=/settings"> 224 213 <style> 225 214 body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; } 226 215 .success { background: #d4edda; border: 1px solid #c3e6cb; padding: 20px; border-radius: 5px; } 227 - code { background: #f5f5f5; padding: 10px; display: block; margin: 10px 0; word-break: break-all; } 228 - .copy-btn { background: #007bff; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 5px; } 229 - .copy-btn:hover { background: #0056b3; } 216 + .info { background: #d1ecf1; border: 1px solid #bee5eb; padding: 15px; border-radius: 5px; margin-top: 15px; } 217 + a { color: #007bff; text-decoration: none; } 218 + a:hover { text-decoration: underline; } 230 219 </style> 231 220 </head> 232 221 <body> 233 222 <div class="success"> 234 223 <h1>✓ Authorization Successful!</h1> 235 224 <p>You have successfully authorized ATCR to access your ATProto account: <strong>{{.Handle}}</strong></p> 236 - <p>Copy the session token below and paste it into your credential helper:</p> 237 - <code id="token">{{.SessionToken}}</code> 238 - <button class="copy-btn" onclick="copyToken()">Copy Token</button> 225 + <p>Redirecting to settings page to generate your API key...</p> 226 + <p>If not redirected, <a href="/settings">click here</a>.</p> 227 + </div> 228 + <div class="info"> 229 + <h3>Next Steps:</h3> 230 + <ol> 231 + <li>Generate an API key on the settings page</li> 232 + <li>Copy the API key (shown once!)</li> 233 + <li>Use it with: <code>docker login atcr.io -u {{.Handle}} -p [your-api-key]</code></li> 234 + </ol> 239 235 </div> 240 - <script> 241 - function copyToken() { 242 - const token = document.getElementById('token').textContent; 243 - navigator.clipboard.writeText(token).then(() => { 244 - alert('Token copied to clipboard!'); 245 - }); 246 - } 247 - </script> 248 236 </body> 249 237 </html> 250 238 `
+237
pkg/auth/oauth/store.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "path/filepath" 9 + "sync" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + ) 15 + 16 + // FileStore implements oauth.ClientAuthStore with file-based persistence 17 + type FileStore struct { 18 + path string 19 + sessions map[string]*oauth.ClientSessionData // Key: "did:sessionID" 20 + requests map[string]*oauth.AuthRequestData // Key: state 21 + mu sync.RWMutex 22 + } 23 + 24 + // FileStoreData represents the JSON structure stored on disk 25 + type FileStoreData struct { 26 + Sessions map[string]*oauth.ClientSessionData `json:"sessions"` 27 + Requests map[string]*oauth.AuthRequestData `json:"requests"` 28 + } 29 + 30 + // NewFileStore creates a new file-based OAuth store 31 + func NewFileStore(path string) (*FileStore, error) { 32 + store := &FileStore{ 33 + path: path, 34 + sessions: make(map[string]*oauth.ClientSessionData), 35 + requests: make(map[string]*oauth.AuthRequestData), 36 + } 37 + 38 + // Load existing data if file exists 39 + if err := store.load(); err != nil { 40 + if !os.IsNotExist(err) { 41 + return nil, fmt.Errorf("failed to load store: %w", err) 42 + } 43 + // File doesn't exist yet, that's ok 44 + } 45 + 46 + return store, nil 47 + } 48 + 49 + // GetDefaultStorePath returns the default storage path for OAuth data 50 + func GetDefaultStorePath() (string, error) { 51 + // For AppView: /var/lib/atcr/oauth-sessions.json 52 + // For CLI tools: ~/.atcr/oauth-sessions.json 53 + 54 + // Check if running as a service (has write access to /var/lib) 55 + servicePath := "/var/lib/atcr/oauth-sessions.json" 56 + if err := os.MkdirAll(filepath.Dir(servicePath), 0700); err == nil { 57 + // Can write to /var/lib, use service path 58 + return servicePath, nil 59 + } 60 + 61 + // Fall back to user home directory 62 + homeDir, err := os.UserHomeDir() 63 + if err != nil { 64 + return "", fmt.Errorf("failed to get home directory: %w", err) 65 + } 66 + 67 + atcrDir := filepath.Join(homeDir, ".atcr") 68 + if err := os.MkdirAll(atcrDir, 0700); err != nil { 69 + return "", fmt.Errorf("failed to create .atcr directory: %w", err) 70 + } 71 + 72 + return filepath.Join(atcrDir, "oauth-sessions.json"), nil 73 + } 74 + 75 + // GetSession retrieves a session by DID and session ID 76 + func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 77 + s.mu.RLock() 78 + defer s.mu.RUnlock() 79 + 80 + key := makeSessionKey(did.String(), sessionID) 81 + session, ok := s.sessions[key] 82 + if !ok { 83 + return nil, fmt.Errorf("session not found: %s/%s", did, sessionID) 84 + } 85 + 86 + return session, nil 87 + } 88 + 89 + // SaveSession saves or updates a session (upsert) 90 + func (s *FileStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 91 + s.mu.Lock() 92 + defer s.mu.Unlock() 93 + 94 + key := makeSessionKey(sess.AccountDID.String(), sess.SessionID) 95 + s.sessions[key] = &sess 96 + 97 + return s.save() 98 + } 99 + 100 + // DeleteSession removes a session 101 + func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 102 + s.mu.Lock() 103 + defer s.mu.Unlock() 104 + 105 + key := makeSessionKey(did.String(), sessionID) 106 + delete(s.sessions, key) 107 + 108 + return s.save() 109 + } 110 + 111 + // GetAuthRequestInfo retrieves authentication request data by state 112 + func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 113 + s.mu.RLock() 114 + defer s.mu.RUnlock() 115 + 116 + request, ok := s.requests[state] 117 + if !ok { 118 + return nil, fmt.Errorf("auth request not found: %s", state) 119 + } 120 + 121 + return request, nil 122 + } 123 + 124 + // SaveAuthRequestInfo saves authentication request data 125 + func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 126 + s.mu.Lock() 127 + defer s.mu.Unlock() 128 + 129 + s.requests[info.State] = &info 130 + 131 + return s.save() 132 + } 133 + 134 + // DeleteAuthRequestInfo removes authentication request data 135 + func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 136 + s.mu.Lock() 137 + defer s.mu.Unlock() 138 + 139 + delete(s.requests, state) 140 + 141 + return s.save() 142 + } 143 + 144 + // CleanupExpired removes expired sessions and auth requests 145 + // Should be called periodically (e.g., every hour) 146 + func (s *FileStore) CleanupExpired() error { 147 + s.mu.Lock() 148 + defer s.mu.Unlock() 149 + 150 + now := time.Now() 151 + modified := false 152 + 153 + // Clean up auth requests older than 10 minutes 154 + // (OAuth flows should complete quickly) 155 + for state := range s.requests { 156 + // Note: AuthRequestData doesn't have a timestamp in indigo's implementation 157 + // For now, we'll rely on the OAuth server's cleanup routine 158 + // or we could extend AuthRequestData with metadata 159 + _ = state // Placeholder for future expiration logic 160 + } 161 + 162 + // Sessions don't have expiry in the data structure 163 + // Cleanup would need to be token-based (check token expiry) 164 + // For now, manual cleanup via DeleteSession 165 + _ = now 166 + 167 + if modified { 168 + return s.save() 169 + } 170 + 171 + return nil 172 + } 173 + 174 + // ListSessions returns all stored sessions for debugging/management 175 + func (s *FileStore) ListSessions() map[string]*oauth.ClientSessionData { 176 + s.mu.RLock() 177 + defer s.mu.RUnlock() 178 + 179 + // Return a copy to prevent external modification 180 + result := make(map[string]*oauth.ClientSessionData) 181 + for k, v := range s.sessions { 182 + result[k] = v 183 + } 184 + return result 185 + } 186 + 187 + // load reads data from disk 188 + func (s *FileStore) load() error { 189 + data, err := os.ReadFile(s.path) 190 + if err != nil { 191 + return err 192 + } 193 + 194 + var storeData FileStoreData 195 + if err := json.Unmarshal(data, &storeData); err != nil { 196 + return fmt.Errorf("failed to parse store: %w", err) 197 + } 198 + 199 + if storeData.Sessions != nil { 200 + s.sessions = storeData.Sessions 201 + } 202 + if storeData.Requests != nil { 203 + s.requests = storeData.Requests 204 + } 205 + 206 + return nil 207 + } 208 + 209 + // save writes data to disk 210 + func (s *FileStore) save() error { 211 + storeData := FileStoreData{ 212 + Sessions: s.sessions, 213 + Requests: s.requests, 214 + } 215 + 216 + data, err := json.MarshalIndent(storeData, "", " ") 217 + if err != nil { 218 + return fmt.Errorf("failed to marshal store: %w", err) 219 + } 220 + 221 + // Ensure directory exists 222 + if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { 223 + return fmt.Errorf("failed to create directory: %w", err) 224 + } 225 + 226 + // Write with restrictive permissions 227 + if err := os.WriteFile(s.path, data, 0600); err != nil { 228 + return fmt.Errorf("failed to write store: %w", err) 229 + } 230 + 231 + return nil 232 + } 233 + 234 + // makeSessionKey creates a composite key for session storage 235 + func makeSessionKey(did, sessionID string) string { 236 + return fmt.Sprintf("%s:%s", did, sessionID) 237 + }
-170
pkg/auth/session/handler.go
··· 1 - package session 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/rand" 6 - "crypto/sha256" 7 - "encoding/base64" 8 - "encoding/json" 9 - "fmt" 10 - "os" 11 - "strings" 12 - "time" 13 - ) 14 - 15 - // SessionClaims represents the data stored in a session token 16 - type SessionClaims struct { 17 - DID string `json:"did"` 18 - Handle string `json:"handle"` 19 - IssuedAt time.Time `json:"issued_at"` 20 - ExpiresAt time.Time `json:"expires_at"` 21 - } 22 - 23 - // Manager handles session token creation and validation 24 - type Manager struct { 25 - secret []byte 26 - ttl time.Duration 27 - } 28 - 29 - // NewManager creates a new session manager 30 - func NewManager(secret []byte, ttl time.Duration) *Manager { 31 - return &Manager{ 32 - secret: secret, 33 - ttl: ttl, 34 - } 35 - } 36 - 37 - // NewManagerWithRandomSecret creates a session manager with a random secret 38 - func NewManagerWithRandomSecret(ttl time.Duration) (*Manager, error) { 39 - secret := make([]byte, 32) 40 - if _, err := rand.Read(secret); err != nil { 41 - return nil, fmt.Errorf("failed to generate secret: %w", err) 42 - } 43 - return NewManager(secret, ttl), nil 44 - } 45 - 46 - // NewManagerWithPersistentSecret creates a session manager with a persistent secret 47 - // The secret is stored at secretPath and reused across restarts 48 - func NewManagerWithPersistentSecret(secretPath string, ttl time.Duration) (*Manager, error) { 49 - var secret []byte 50 - 51 - // Try to load existing secret 52 - if data, err := os.ReadFile(secretPath); err == nil { 53 - secret = data 54 - fmt.Printf("Loaded existing session secret from %s\n", secretPath) 55 - } else if os.IsNotExist(err) { 56 - // Generate new secret 57 - secret = make([]byte, 32) 58 - if _, err := rand.Read(secret); err != nil { 59 - return nil, fmt.Errorf("failed to generate secret: %w", err) 60 - } 61 - 62 - // Save secret for future restarts 63 - if err := os.WriteFile(secretPath, secret, 0600); err != nil { 64 - return nil, fmt.Errorf("failed to save secret: %w", err) 65 - } 66 - fmt.Printf("Generated and saved new session secret to %s\n", secretPath) 67 - } else { 68 - return nil, fmt.Errorf("failed to read secret file: %w", err) 69 - } 70 - 71 - return NewManager(secret, ttl), nil 72 - } 73 - 74 - // Create generates a new session token for a DID 75 - func (m *Manager) Create(did, handle string) (string, error) { 76 - now := time.Now() 77 - claims := SessionClaims{ 78 - DID: did, 79 - Handle: handle, 80 - IssuedAt: now, 81 - ExpiresAt: now.Add(m.ttl), 82 - } 83 - 84 - // Marshal claims to JSON 85 - claimsJSON, err := json.Marshal(claims) 86 - if err != nil { 87 - return "", fmt.Errorf("failed to marshal claims: %w", err) 88 - } 89 - 90 - // Base64 encode claims 91 - claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON) 92 - 93 - // Generate HMAC signature 94 - sig := m.sign(claimsB64) 95 - sigB64 := base64.RawURLEncoding.EncodeToString(sig) 96 - 97 - // Token format: <claims>.<signature> 98 - token := claimsB64 + "." + sigB64 99 - 100 - return token, nil 101 - } 102 - 103 - // Validate validates a session token and returns the claims 104 - func (m *Manager) Validate(token string) (*SessionClaims, error) { 105 - // Split token into claims and signature 106 - parts := strings.Split(token, ".") 107 - if len(parts) != 2 { 108 - return nil, fmt.Errorf("invalid token format") 109 - } 110 - 111 - claimsB64 := parts[0] 112 - sigB64 := parts[1] 113 - 114 - // Verify signature 115 - expectedSig := m.sign(claimsB64) 116 - providedSig, err := base64.RawURLEncoding.DecodeString(sigB64) 117 - if err != nil { 118 - return nil, fmt.Errorf("invalid signature encoding: %w", err) 119 - } 120 - 121 - if !hmac.Equal(expectedSig, providedSig) { 122 - return nil, fmt.Errorf("invalid signature") 123 - } 124 - 125 - // Decode claims 126 - claimsJSON, err := base64.RawURLEncoding.DecodeString(claimsB64) 127 - if err != nil { 128 - return nil, fmt.Errorf("invalid claims encoding: %w", err) 129 - } 130 - 131 - var claims SessionClaims 132 - if err := json.Unmarshal(claimsJSON, &claims); err != nil { 133 - return nil, fmt.Errorf("invalid claims format: %w", err) 134 - } 135 - 136 - // Check expiration 137 - if time.Now().After(claims.ExpiresAt) { 138 - return nil, fmt.Errorf("token expired") 139 - } 140 - 141 - return &claims, nil 142 - } 143 - 144 - // sign generates HMAC-SHA256 signature for data 145 - func (m *Manager) sign(data string) []byte { 146 - h := hmac.New(sha256.New, m.secret) 147 - h.Write([]byte(data)) 148 - return h.Sum(nil) 149 - } 150 - 151 - // GetDID extracts the DID from a token without full validation 152 - // Useful for logging/debugging 153 - func (m *Manager) GetDID(token string) (string, error) { 154 - parts := strings.Split(token, ".") 155 - if len(parts) != 2 { 156 - return "", fmt.Errorf("invalid token format") 157 - } 158 - 159 - claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) 160 - if err != nil { 161 - return "", fmt.Errorf("invalid claims encoding: %w", err) 162 - } 163 - 164 - var claims SessionClaims 165 - if err := json.Unmarshal(claimsJSON, &claims); err != nil { 166 - return "", fmt.Errorf("invalid claims format: %w", err) 167 - } 168 - 169 - return claims.DID, nil 170 - }
+43 -28
pkg/auth/token/handler.go
··· 7 7 "strings" 8 8 "time" 9 9 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + 13 + "atcr.io/pkg/appview/apikey" 10 14 mainAtproto "atcr.io/pkg/atproto" 11 15 "atcr.io/pkg/auth" 12 16 "atcr.io/pkg/auth/atproto" 13 - "atcr.io/pkg/auth/session" 14 17 ) 15 18 16 19 // Handler handles /auth/token requests 17 20 type Handler struct { 18 21 issuer *Issuer 19 22 validator *atproto.SessionValidator 20 - sessionManager *session.Manager // For validating session tokens 23 + apiKeyStore *apikey.Store // For validating API keys 21 24 defaultHoldEndpoint string 22 25 } 23 26 24 27 // NewHandler creates a new token handler 25 - func NewHandler(issuer *Issuer, sessionManager *session.Manager, defaultHoldEndpoint string) *Handler { 28 + func NewHandler(issuer *Issuer, apiKeyStore *apikey.Store, defaultHoldEndpoint string) *Handler { 26 29 return &Handler{ 27 30 issuer: issuer, 28 31 validator: atproto.NewSessionValidator(), 29 - sessionManager: sessionManager, 32 + apiKeyStore: apiKeyStore, 30 33 defaultHoldEndpoint: defaultHoldEndpoint, 31 34 } 32 35 } ··· 80 83 var handle string 81 84 var accessToken string 82 85 83 - // Try to validate as session token first (our OAuth flow) 84 - // Session tokens have format: <base64_claims>.<base64_signature> 85 - sessionClaims, sessionErr := h.sessionManager.Validate(password) 86 - if sessionErr == nil { 87 - // Successfully validated as session token 88 - did = sessionClaims.DID 89 - handle = sessionClaims.Handle 90 - fmt.Printf("DEBUG [token/handler]: Session token validated for DID=%s, handle=%s\n", did, handle) 91 - // For session tokens, we don't have a PDS access token here 92 - // The registry will use OAuth refresh tokens to get one when needed 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) 89 + if err != nil { 90 + fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err) 91 + w.Header().Set("WWW-Authenticate", `Basic realm="ATCR Registry"`) 92 + http.Error(w, "authentication failed", http.StatusUnauthorized) 93 + return 94 + } 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) 99 + 100 + // API key is linked to OAuth session 101 + // OAuth refresher will provide access token when needed via middleware 93 102 } else { 94 - // Not a session token, try app password (Basic Auth flow) 95 - fmt.Printf("DEBUG [token/handler]: Not a session token, trying app password for %s\n", username) 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) 96 105 did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password) 97 106 if err != nil { 98 107 fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err) ··· 110 119 111 120 // Ensure user profile exists (creates with default hold if needed) 112 121 // Resolve PDS endpoint for profile management 113 - resolver := mainAtproto.NewResolver() 114 - _, pdsEndpoint, err := resolver.ResolveIdentity(r.Context(), username) 115 - if err != nil { 116 - // Log error but don't fail auth - profile management is not critical 117 - fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 118 - } else { 119 - // Create ATProto client with validated token 120 - atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) 122 + directory := identity.DefaultDirectory() 123 + atID, err := syntax.ParseAtIdentifier(username) 124 + if err == nil { 125 + ident, err := directory.Lookup(r.Context(), *atID) 126 + if err != nil { 127 + // Log error but don't fail auth - profile management is not critical 128 + fmt.Printf("WARNING: failed to resolve PDS for profile management: %v\n", err) 129 + } else { 130 + pdsEndpoint := ident.PDSEndpoint() 131 + if pdsEndpoint != "" { 132 + // Create ATProto client with validated token 133 + atprotoClient := mainAtproto.NewClient(pdsEndpoint, did, accessToken) 121 134 122 - // Ensure profile exists (will create with default hold if not exists and default is configured) 123 - if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 124 - // Log error but don't fail auth - profile management is not critical 125 - fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 135 + // Ensure profile exists (will create with default hold if not exists and default is configured) 136 + if err := mainAtproto.EnsureProfile(r.Context(), atprotoClient, h.defaultHoldEndpoint); err != nil { 137 + // Log error but don't fail auth - profile management is not critical 138 + fmt.Printf("WARNING: failed to ensure profile for %s: %v\n", did, err) 139 + } 140 + } 126 141 } 127 142 } 128 143 }
+27 -18
pkg/middleware/registry.go
··· 7 7 "strings" 8 8 "sync" 9 9 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 10 12 "github.com/distribution/distribution/v3" 11 13 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry" 12 14 "github.com/distribution/distribution/v3/registry/storage/driver" ··· 34 36 // NamespaceResolver wraps a namespace and resolves names 35 37 type NamespaceResolver struct { 36 38 distribution.Namespace 37 - resolver *atproto.Resolver 39 + directory identity.Directory 38 40 defaultStorageEndpoint string 39 41 repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) 40 42 } 41 43 42 44 // initATProtoResolver initializes the name resolution middleware 43 45 func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) { 44 - resolver := atproto.NewResolver() 46 + // Use indigo's default directory (includes caching) 47 + directory := identity.DefaultDirectory() 45 48 46 49 // Get default storage endpoint from config (optional) 47 50 defaultStorageEndpoint := "" ··· 51 54 52 55 return &NamespaceResolver{ 53 56 Namespace: ns, 54 - resolver: resolver, 57 + directory: directory, 55 58 defaultStorageEndpoint: defaultStorageEndpoint, 56 59 }, nil 57 60 } ··· 70 73 return nil, fmt.Errorf("repository name must include user: %s", repoPath) 71 74 } 72 75 73 - identity := parts[0] 76 + identityStr := parts[0] 74 77 imageName := parts[1] 75 78 76 - // Resolve identity to DID and PDS 77 - did, pdsEndpoint, err := nr.resolver.ResolveIdentity(ctx, identity) 79 + // Parse identity (handle or DID) 80 + atID, err := syntax.ParseAtIdentifier(identityStr) 81 + if err != nil { 82 + return nil, fmt.Errorf("invalid identity %s: %w", identityStr, err) 83 + } 84 + 85 + // Resolve identity to DID and PDS using indigo's directory 86 + ident, err := nr.directory.Lookup(ctx, *atID) 78 87 if err != nil { 79 - return nil, fmt.Errorf("failed to resolve identity %s: %w", identity, err) 88 + return nil, fmt.Errorf("failed to resolve identity %s: %w", identityStr, err) 80 89 } 81 90 82 - // Store resolved DID and PDS in context for downstream use 83 - ctx = context.WithValue(ctx, "atproto.did", did) 84 - ctx = context.WithValue(ctx, "atproto.pds", pdsEndpoint) 85 - ctx = context.WithValue(ctx, "atproto.identity", identity) 91 + did := ident.DID.String() 92 + pdsEndpoint := ident.PDSEndpoint() 93 + if pdsEndpoint == "" { 94 + return nil, fmt.Errorf("no PDS endpoint found for %s", identityStr) 95 + } 86 96 87 - fmt.Printf("DEBUG [registry/middleware]: Set context values: did=%s, pds=%s, identity=%s\n", did, pdsEndpoint, identity) 97 + fmt.Printf("DEBUG [registry/middleware]: Resolved identity: did=%s, pds=%s, handle=%s\n", did, pdsEndpoint, ident.Handle.String()) 88 98 89 99 // Query for storage endpoint - either user's hold or default hold service 90 100 storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint) ··· 98 108 // Create a new reference with identity/image format 99 109 // Use the identity (or DID) as the namespace to ensure canonical format 100 110 // This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path) 101 - canonicalName := fmt.Sprintf("%s/%s", identity, imageName) 111 + canonicalName := fmt.Sprintf("%s/%s", identityStr, imageName) 102 112 ref, err := reference.ParseNamed(canonicalName) 103 113 if err != nil { 104 114 return nil, fmt.Errorf("invalid image name %s: %w", imageName, err) ··· 119 129 // Try OAuth flow first 120 130 session, err := globalRefresher.GetSession(ctx, did) 121 131 if err == nil { 122 - // OAuth session available 123 - accessToken, _ := session.GetHostAccessData() 124 - httpClient := session.APIClient().Client 125 - fmt.Printf("DEBUG [registry/middleware]: Using OAuth access token for DID=%s (length=%d, first_20=%q)\n", did, len(accessToken), accessToken[:min(20, len(accessToken))]) 126 - atprotoClient = atproto.NewClientWithHTTPClient(pdsEndpoint, did, accessToken, httpClient) 132 + // OAuth session available - use indigo's API client (handles DPoP automatically) 133 + apiClient := session.APIClient() 134 + fmt.Printf("DEBUG [registry/middleware]: Using OAuth session with indigo API client for DID=%s\n", did) 135 + atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient) 127 136 } else { 128 137 fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err) 129 138 }
-57
pkg/middleware/repository.go
··· 1 - package middleware 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - 7 - "github.com/distribution/distribution/v3" 8 - repositorymw "github.com/distribution/distribution/v3/registry/middleware/repository" 9 - 10 - "atcr.io/pkg/atproto" 11 - "atcr.io/pkg/storage" 12 - ) 13 - 14 - func init() { 15 - // Register the ATProto routing middleware 16 - repositorymw.Register("atproto-router", initATProtoRouter) 17 - } 18 - 19 - // initATProtoRouter initializes the ATProto routing middleware 20 - func initATProtoRouter(ctx context.Context, repo distribution.Repository, options map[string]any) (distribution.Repository, error) { 21 - fmt.Printf("DEBUG [repository/middleware]: Initializing atproto-router for repo=%s\n", repo.Named().Name()) 22 - fmt.Printf("DEBUG [repository/middleware]: Context values: atproto.did=%v, atproto.pds=%v\n", 23 - ctx.Value("atproto.did"), ctx.Value("atproto.pds")) 24 - 25 - // Extract DID and PDS from context (set by registry middleware) 26 - did, ok := ctx.Value("atproto.did").(string) 27 - if !ok || did == "" { 28 - fmt.Printf("DEBUG [repository/middleware]: DID not found in context, ok=%v, did=%q\n", ok, did) 29 - return nil, fmt.Errorf("did is required for atproto-router middleware") 30 - } 31 - 32 - pdsEndpoint, ok := ctx.Value("atproto.pds").(string) 33 - if !ok || pdsEndpoint == "" { 34 - return nil, fmt.Errorf("pds is required for atproto-router middleware") 35 - } 36 - 37 - // For now, use empty access token (we'll add auth later) 38 - accessToken := "" 39 - 40 - // Create ATProto client 41 - atprotoClient := atproto.NewClient(pdsEndpoint, did, accessToken) 42 - 43 - // Get repository name 44 - repoName := repo.Named().Name() 45 - 46 - // Get storage endpoint from context 47 - storageEndpoint, ok := ctx.Value("storage.endpoint").(string) 48 - if !ok || storageEndpoint == "" { 49 - return nil, fmt.Errorf("storage.endpoint not found in context") 50 - } 51 - 52 - // Create routing repository - no longer uses storage driver 53 - // All blobs are routed through hold service 54 - routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repoName, storageEndpoint, did) 55 - 56 - return routingRepo, nil 57 - }
+5 -5
pkg/server/handler.go
··· 4 4 "net/http" 5 5 "strings" 6 6 7 - "atcr.io/pkg/atproto" 7 + "github.com/bluesky-social/indigo/atproto/identity" 8 8 ) 9 9 10 10 // ATProtoHandler wraps an HTTP handler to provide name resolution 11 11 // This is an optional layer if middleware doesn't provide enough control 12 12 type ATProtoHandler struct { 13 - handler http.Handler 14 - resolver *atproto.Resolver 13 + handler http.Handler 14 + directory identity.Directory 15 15 } 16 16 17 17 // NewATProtoHandler creates a new HTTP handler wrapper 18 18 func NewATProtoHandler(handler http.Handler) *ATProtoHandler { 19 19 return &ATProtoHandler{ 20 - handler: handler, 21 - resolver: atproto.NewResolver(), 20 + handler: handler, 21 + directory: identity.DefaultDirectory(), 22 22 } 23 23 } 24 24