A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
fork

Configure Feed

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

fix app-passwords remove dead code

+9 -540
-122
cmd/profile-update/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "encoding/base64" 6 - "encoding/json" 7 - "flag" 8 - "fmt" 9 - "log" 10 - "os" 11 - "path/filepath" 12 - "strings" 13 - 14 - "atcr.io/pkg/atproto" 15 - atprotoAuth "atcr.io/pkg/auth/atproto" 16 - ) 17 - 18 - // DockerConfig represents ~/.docker/config.json 19 - type DockerConfig struct { 20 - Auths map[string]AuthEntry `json:"auths"` 21 - } 22 - 23 - type AuthEntry struct { 24 - Auth string `json:"auth"` // base64(username:password) 25 - } 26 - 27 - func main() { 28 - var defaultHold string 29 - var registryURL string 30 - 31 - flag.StringVar(&defaultHold, "default-hold", "", "Default hold endpoint URL (e.g., http://172.28.0.3:8080)") 32 - flag.StringVar(&registryURL, "registry", "127.0.0.1:5000", "Registry URL to read auth from Docker config") 33 - flag.Parse() 34 - 35 - // Read Docker config 36 - home, err := os.UserHomeDir() 37 - if err != nil { 38 - log.Fatalf("Failed to get home directory: %v", err) 39 - } 40 - dockerConfigPath := filepath.Join(home, ".docker", "config.json") 41 - 42 - configData, err := os.ReadFile(dockerConfigPath) 43 - if err != nil { 44 - log.Fatalf("Failed to read Docker config: %v\n\nMake sure you've logged in with: docker login %s", err, registryURL) 45 - } 46 - 47 - var dockerConfig DockerConfig 48 - if err := json.Unmarshal(configData, &dockerConfig); err != nil { 49 - log.Fatalf("Failed to parse Docker config: %v", err) 50 - } 51 - 52 - // Get auth for registry 53 - authEntry, ok := dockerConfig.Auths[registryURL] 54 - if !ok { 55 - log.Fatalf("No auth found for registry %s in Docker config", registryURL) 56 - } 57 - 58 - // Decode base64 auth (format: "username:password") 59 - authBytes, err := base64.StdEncoding.DecodeString(authEntry.Auth) 60 - if err != nil { 61 - log.Fatalf("Failed to decode auth: %v", err) 62 - } 63 - 64 - parts := strings.SplitN(string(authBytes), ":", 2) 65 - if len(parts) != 2 { 66 - log.Fatalf("Invalid auth format") 67 - } 68 - 69 - handle := parts[0] 70 - password := parts[1] // This should be an app password 71 - 72 - fmt.Printf("Handle: %s\n", handle) 73 - 74 - // Create session validator and get access token 75 - validator := atprotoAuth.NewSessionValidator() 76 - ctx := context.Background() 77 - 78 - did, pdsEndpoint, accessToken, err := validator.CreateSessionAndGetToken(ctx, handle, password) 79 - if err != nil { 80 - log.Fatalf("Failed to authenticate: %v", err) 81 - } 82 - 83 - fmt.Printf("DID: %s\n", did) 84 - fmt.Printf("PDS: %s\n\n", pdsEndpoint) 85 - 86 - // Create client with the access token from createSession 87 - client := atproto.NewClient(pdsEndpoint, did, accessToken) 88 - 89 - // Get current profile 90 - profile, err := atproto.GetProfile(ctx, client) 91 - if err != nil { 92 - log.Fatalf("Failed to get current profile: %v", err) 93 - } 94 - 95 - if profile == nil { 96 - if defaultHold == "" { 97 - fmt.Println("No existing profile found.") 98 - fmt.Println("\nTo create profile with default hold, use: -default-hold <url>") 99 - return 100 - } 101 - fmt.Println("No existing profile found. Creating new profile...") 102 - profile = atproto.NewSailorProfileRecord(defaultHold) 103 - } else { 104 - fmt.Printf("Current defaultHold: %s\n", profile.DefaultHold) 105 - if defaultHold == "" { 106 - // Just show current profile 107 - fmt.Println("\nTo update, use: -default-hold <url>") 108 - return 109 - } 110 - profile.DefaultHold = defaultHold 111 - } 112 - 113 - // Update profile 114 - if defaultHold != "" { 115 - err = atproto.UpdateProfile(ctx, client, profile) 116 - if err != nil { 117 - log.Fatalf("Failed to update profile: %v", err) 118 - } 119 - 120 - fmt.Printf("\n✓ Updated defaultHold to: %s\n", defaultHold) 121 - } 122 - }
+6 -40
pkg/auth/atproto/session.go
··· 19 19 // CachedSession represents a cached session 20 20 type CachedSession struct { 21 21 DID string 22 + Handle string 22 23 PDS string 23 24 AccessToken string 24 25 ExpiresAt time.Time ··· 83 84 AccessToken string `json:"access_token,omitempty"` // Alternative field name 84 85 } 85 86 86 - // ValidateCredentials validates username and password against ATProto 87 - // Returns the user's DID and PDS endpoint if valid 88 - func (v *SessionValidator) ValidateCredentials(ctx context.Context, identifier, password string) (did, pdsEndpoint string, err error) { 89 - // Resolve identifier (handle or DID) to PDS endpoint 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) 96 - if err != nil { 97 - return "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err) 98 - } 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 - 106 - fmt.Printf("DEBUG: Resolved %s to DID=%s, PDS=%s\n", identifier, resolvedDID, pds) 107 - 108 - // Create session with the PDS 109 - fmt.Printf("DEBUG [atproto/session]: Creating session for %s at PDS %s\n", identifier, pds) 110 - sessionResp, err := v.createSession(ctx, pds, identifier, password) 111 - if err != nil { 112 - fmt.Printf("DEBUG [atproto/session]: Session creation failed: %v\n", err) 113 - return "", "", fmt.Errorf("authentication failed for %s at PDS %s: %w", identifier, pds, err) 114 - } 115 - 116 - fmt.Printf("DEBUG [atproto/session]: Session created successfully, DID=%s, Handle=%s, AccessJWT length=%d\n", 117 - sessionResp.DID, sessionResp.Handle, len(sessionResp.AccessJWT)) 118 - 119 - return sessionResp.DID, pds, nil 120 - } 121 - 122 - // CreateSessionAndGetToken creates a session and returns the DID, PDS endpoint, and access token 123 - func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, pdsEndpoint, accessToken string, err error) { 87 + // CreateSessionAndGetToken creates a session and returns the DID, handle, and access token 88 + func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, handle, accessToken string, err error) { 124 89 // Check cache first 125 90 cacheKey := getCacheKey(identifier, password) 126 91 if cached, ok := v.getCachedSession(cacheKey); ok { 127 92 fmt.Printf("DEBUG [atproto/session]: Using cached session for %s (DID=%s)\n", identifier, cached.DID) 128 - return cached.DID, cached.PDS, cached.AccessToken, nil 93 + return cached.DID, cached.Handle, cached.AccessToken, nil 129 94 } 130 95 131 96 fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier) ··· 156 121 // Cache the session (ATProto sessions typically last 2 hours) 157 122 v.setCachedSession(cacheKey, &CachedSession{ 158 123 DID: sessionResp.DID, 124 + Handle: sessionResp.Handle, 159 125 PDS: pds, 160 126 AccessToken: sessionResp.AccessJWT, 161 127 ExpiresAt: time.Now().Add(2 * time.Hour), 162 128 }) 163 129 fmt.Printf("DEBUG [atproto/session]: Cached session for %s (expires in 2 hours)\n", identifier) 164 130 165 - return sessionResp.DID, pds, sessionResp.AccessJWT, nil 131 + return sessionResp.DID, sessionResp.Handle, sessionResp.AccessJWT, nil 166 132 } 167 133 168 134 // createSession calls com.atproto.server.createSession
-112
pkg/auth/atproto/validator.go
··· 1 - package atproto 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - ) 13 - 14 - // TokenValidator validates ATProto OAuth access tokens 15 - type TokenValidator struct { 16 - httpClient *http.Client 17 - } 18 - 19 - // NewTokenValidator creates a new token validator 20 - func NewTokenValidator() *TokenValidator { 21 - return &TokenValidator{ 22 - httpClient: &http.Client{}, 23 - } 24 - } 25 - 26 - // SessionInfo represents the response from com.atproto.server.getSession 27 - type SessionInfo struct { 28 - DID string `json:"did"` 29 - Handle string `json:"handle"` 30 - Email string `json:"email,omitempty"` 31 - EmailConfirmed bool `json:"emailConfirmed,omitempty"` 32 - Active bool `json:"active,omitempty"` 33 - } 34 - 35 - // ValidateToken validates an ATProto OAuth access token by calling getSession 36 - // Returns the user's DID and handle if the token is valid 37 - // dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer 38 - func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) { 39 - // Call com.atproto.server.getSession with the access token 40 - url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint) 41 - 42 - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 43 - if err != nil { 44 - return nil, fmt.Errorf("failed to create request: %w", err) 45 - } 46 - 47 - // Always use Bearer auth for getSession validation 48 - // The DPoP proof from the client is bound to their request to us (POST /auth/exchange), 49 - // not to our request to the PDS (GET /getSession) 50 - req.Header.Set("Authorization", "Bearer "+accessToken) 51 - 52 - fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n", 53 - url, accessToken[:min(20, len(accessToken))]) 54 - 55 - resp, err := v.httpClient.Do(req) 56 - if err != nil { 57 - return nil, fmt.Errorf("failed to get session: %w", err) 58 - } 59 - defer resp.Body.Close() 60 - 61 - // Read body once for both logging and error handling 62 - bodyBytes, _ := io.ReadAll(resp.Body) 63 - 64 - if resp.StatusCode == http.StatusUnauthorized { 65 - fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes)) 66 - return nil, fmt.Errorf("invalid or expired token") 67 - } 68 - 69 - if resp.StatusCode != http.StatusOK { 70 - fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes)) 71 - return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes)) 72 - } 73 - 74 - var session SessionInfo 75 - if err := json.Unmarshal(bodyBytes, &session); err != nil { 76 - return nil, fmt.Errorf("failed to decode session: %w", err) 77 - } 78 - 79 - // Validate required fields 80 - if session.DID == "" { 81 - return nil, fmt.Errorf("session response missing DID") 82 - } 83 - if session.Handle == "" { 84 - return nil, fmt.Errorf("session response missing handle") 85 - } 86 - 87 - return &session, nil 88 - } 89 - 90 - // ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint 91 - // dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer 92 - func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) { 93 - // Resolve handle to PDS endpoint 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) 101 - if err != nil { 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) 108 - } 109 - 110 - // Validate token against the PDS 111 - return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof) 112 - }
-87
pkg/auth/oauth/interactive.go
··· 4 4 "context" 5 5 "fmt" 6 6 "net/http" 7 - "net/url" 8 - "sync" 9 7 "time" 10 8 11 9 "github.com/bluesky-social/indigo/atproto/auth/oauth" ··· 16 14 SessionData *oauth.ClientSessionData 17 15 Session *oauth.ClientSession 18 16 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 17 } 105 18 106 19 // InteractiveFlowWithCallback runs an interactive OAuth flow with explicit callback handling
-97
pkg/auth/oauth/refresher.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 - "net/http" 7 6 "sync" 8 7 9 8 "github.com/bluesky-social/indigo/atproto/auth/oauth" ··· 74 73 return r.resumeSession(ctx, did) 75 74 } 76 75 77 - // GetAccessToken gets a fresh access token for a DID 78 - // This is a convenience method that extracts the access token from the session 79 - func (r *Refresher) GetAccessToken(ctx context.Context, did string) (string, error) { 80 - session, err := r.GetSession(ctx, did) 81 - if err != nil { 82 - return "", err 83 - } 84 - 85 - // Get access token and DPoP nonce from session 86 - accessToken, _ := session.GetHostAccessData() 87 - return accessToken, nil 88 - } 89 - 90 - // GetHTTPClient returns an HTTP client with DPoP authentication for a DID 91 - // The client automatically adds DPoP headers and refreshes tokens as needed 92 - func (r *Refresher) GetHTTPClient(ctx context.Context, did string) (*http.Client, error) { 93 - session, err := r.GetSession(ctx, did) 94 - if err != nil { 95 - return nil, err 96 - } 97 - 98 - // Get API client from session 99 - // This client automatically handles DPoP and token refresh 100 - apiClient := session.APIClient() 101 - return apiClient.Client, nil 102 - } 103 - 104 76 // resumeSession loads a session from storage and caches it 105 77 func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.ClientSession, error) { 106 78 // Parse DID ··· 153 125 delete(r.sessions, did) 154 126 r.mu.Unlock() 155 127 } 156 - 157 - // RevokeSession removes a session from both cache and storage 158 - func (r *Refresher) RevokeSession(ctx context.Context, did string) error { 159 - // Remove from cache 160 - r.mu.Lock() 161 - cached, ok := r.sessions[did] 162 - delete(r.sessions, did) 163 - r.mu.Unlock() 164 - 165 - if !ok { 166 - // Not cached, still try to delete from storage 167 - accountDID, err := syntax.ParseDID(did) 168 - if err != nil { 169 - return fmt.Errorf("failed to parse DID: %w", err) 170 - } 171 - 172 - // Find session ID from store 173 - fileStore, ok := r.app.clientApp.Store.(*FileStore) 174 - if !ok { 175 - return fmt.Errorf("store is not a FileStore") 176 - } 177 - 178 - sessions := fileStore.ListSessions() 179 - for _, sessionData := range sessions { 180 - if sessionData.AccountDID.String() == did { 181 - return r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionData.SessionID) 182 - } 183 - } 184 - 185 - return fmt.Errorf("no session found for DID: %s", did) 186 - } 187 - 188 - // Revoke the session via OAuth 189 - if err := cached.Session.RevokeSession(ctx); err != nil { 190 - fmt.Printf("WARNING: failed to revoke session for %s: %v\n", did, err) 191 - // Continue anyway to delete from storage 192 - } 193 - 194 - // Delete from storage 195 - accountDID, err := syntax.ParseDID(did) 196 - if err != nil { 197 - return fmt.Errorf("failed to parse DID: %w", err) 198 - } 199 - 200 - return r.app.clientApp.Store.DeleteSession(ctx, accountDID, cached.SessionID) 201 - } 202 - 203 - // CleanupExpiredSessions removes expired sessions from cache 204 - // Note: indigo handles token expiry automatically, but we clean up orphaned cache entries 205 - func (r *Refresher) CleanupExpiredSessions(ctx context.Context) { 206 - r.mu.Lock() 207 - defer r.mu.Unlock() 208 - 209 - // For each cached session, verify it still exists in storage 210 - for did, cached := range r.sessions { 211 - accountDID, err := syntax.ParseDID(did) 212 - if err != nil { 213 - delete(r.sessions, did) 214 - continue 215 - } 216 - 217 - // Try to get session from store 218 - _, err = r.app.clientApp.Store.GetSession(ctx, accountDID, cached.SessionID) 219 - if err != nil { 220 - // Session no longer exists, remove from cache 221 - delete(r.sessions, did) 222 - } 223 - } 224 - }
+3 -25
pkg/auth/oauth/server.go
··· 1 1 package oauth 2 2 3 3 import ( 4 - "context" 5 4 "fmt" 6 5 "html/template" 7 6 "net/http" 8 7 "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 8 ) 12 9 13 10 // UISessionStore is the interface for UI session management ··· 102 99 fmt.Printf("DEBUG [oauth/server]: Invalidated cached session for DID=%s after creating new session\n", did) 103 100 } 104 101 105 - // We need to get the handle for UI sessions and settings redirect 106 - // Resolve DID to handle using our resolver 107 - handle, err := s.resolveHandle(r.Context(), did) 102 + // Look up identity 103 + ident, err := s.app.directory.LookupDID(r.Context(), sessionData.AccountDID) 104 + handle := ident.Handle.String() 108 105 if err != nil { 109 106 fmt.Printf("WARNING [oauth/server]: Failed to resolve DID to handle: %v, using DID as handle\n", err) 110 107 handle = did // Fallback to DID if resolution fails ··· 150 147 151 148 // Non-UI flow: redirect to settings to get API key 152 149 s.renderRedirectToSettings(w, handle) 153 - } 154 - 155 - // resolveHandle attempts to resolve a DID to a handle 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) 160 - if err != nil { 161 - return "", fmt.Errorf("invalid DID: %w", err) 162 - } 163 - 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) 168 - } 169 - 170 - // Return handle (may be handle.invalid if verification failed) 171 - return ident.Handle.String(), nil 172 150 } 173 151 174 152 // renderRedirectToSettings redirects to the settings page to generate an API key
-57
pkg/server/handler.go
··· 1 - package server 2 - 3 - import ( 4 - "net/http" 5 - "strings" 6 - 7 - "github.com/bluesky-social/indigo/atproto/identity" 8 - ) 9 - 10 - // ATProtoHandler wraps an HTTP handler to provide name resolution 11 - // This is an optional layer if middleware doesn't provide enough control 12 - type ATProtoHandler struct { 13 - handler http.Handler 14 - directory identity.Directory 15 - } 16 - 17 - // NewATProtoHandler creates a new HTTP handler wrapper 18 - func NewATProtoHandler(handler http.Handler) *ATProtoHandler { 19 - return &ATProtoHandler{ 20 - handler: handler, 21 - directory: identity.DefaultDirectory(), 22 - } 23 - } 24 - 25 - // ServeHTTP handles HTTP requests with name resolution 26 - func (h *ATProtoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 27 - // Parse the request path to extract user/image 28 - // OCI Distribution API paths look like: 29 - // /v2/<name>/manifests/<reference> 30 - // /v2/<name>/blobs/<digest> 31 - 32 - path := r.URL.Path 33 - 34 - // Check if this is a v2 API request 35 - if strings.HasPrefix(path, "/v2/") { 36 - // Extract the repository name 37 - parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/") 38 - if len(parts) >= 2 { 39 - // parts[0] might be username/DID 40 - // We could do early resolution here if needed 41 - // For now, we'll let the middleware handle it 42 - } 43 - } 44 - 45 - // Delegate to the underlying handler 46 - // The registry middleware will handle the actual resolution 47 - h.handler.ServeHTTP(w, r) 48 - } 49 - 50 - // Note: In the current architecture, most of the name resolution 51 - // is handled by the registry middleware. This HTTP handler wrapper 52 - // is here for cases where you need to intercept requests before 53 - // they reach the distribution handlers, such as for: 54 - // - Custom authentication based on DIDs 55 - // - Request rewriting 56 - // - Early validation 57 - // - Custom API endpoints beyond OCI spec