A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
77
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