···2020 "atcr.io/pkg/auth/oauth"
2121)
22222323-// Global refresher instance (set by main.go)
2424-var globalRefresher *oauth.Refresher
2323+// Global variables for initialization only
2424+// These are set by main.go during startup and copied into NamespaceResolver instances.
2525+// After initialization, request handling uses the NamespaceResolver's instance fields.
2626+var (
2727+ globalRefresher *oauth.Refresher
2828+ globalDatabase storage.DatabaseMetrics
2929+ globalAuthorizer auth.HoldAuthorizer
3030+)
25312626-// Global database instance (set by main.go for pull tracking)
2727-var globalDatabase interface {
2828- IncrementPullCount(did, repository string) error
2929- IncrementPushCount(did, repository string) error
3030-}
3131-3232-// Global authorizer instance (set by main.go for hold authorization)
3333-var globalAuthorizer auth.HoldAuthorizer
3434-3535-// SetGlobalRefresher sets the global OAuth refresher instance
3232+// SetGlobalRefresher sets the OAuth refresher instance during initialization
3333+// Must be called before the registry starts serving requests
3634func SetGlobalRefresher(refresher *oauth.Refresher) {
3735 globalRefresher = refresher
3836}
39374040-// SetGlobalDatabase sets the global database instance for metrics tracking
4141-func SetGlobalDatabase(database interface {
4242- IncrementPullCount(did, repository string) error
4343- IncrementPushCount(did, repository string) error
4444-}) {
3838+// SetGlobalDatabase sets the database instance during initialization
3939+// Must be called before the registry starts serving requests
4040+func SetGlobalDatabase(database storage.DatabaseMetrics) {
4541 globalDatabase = database
4642}
47434848-// SetGlobalAuthorizer sets the global authorizer instance for hold access control
4444+// SetGlobalAuthorizer sets the authorizer instance during initialization
4545+// Must be called before the registry starts serving requests
4946func SetGlobalAuthorizer(authorizer auth.HoldAuthorizer) {
5047 globalAuthorizer = authorizer
5148}
···5956type NamespaceResolver struct {
6057 distribution.Namespace
6158 directory identity.Directory
6262- defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
6363- testMode bool // If true, fallback to default hold when user's hold is unreachable
6464- repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
5959+ defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
6060+ testMode bool // If true, fallback to default hold when user's hold is unreachable
6161+ repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
6262+ refresher *oauth.Refresher // OAuth session manager (copied from global on init)
6363+ database storage.DatabaseMetrics // Metrics database (copied from global on init)
6464+ authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
6565}
66666767// initATProtoResolver initializes the name resolution middleware
···8282 testMode = tm
8383 }
84848585+ // Copy shared services from globals into the instance
8686+ // This avoids accessing globals during request handling
8587 return &NamespaceResolver{
8688 Namespace: ns,
8789 directory: directory,
8890 defaultHoldDID: defaultHoldDID,
8991 testMode: testMode,
9292+ refresher: globalRefresher,
9393+ database: globalDatabase,
9494+ authorizer: globalAuthorizer,
9095 }, nil
9196}
9297···155160 // Fall back to Basic Auth token cache (for users who used app passwords)
156161 var atprotoClient *atproto.Client
157162158158- if globalRefresher != nil {
163163+ if nr.refresher != nil {
159164 // Try OAuth flow first
160160- session, err := globalRefresher.GetSession(ctx, did)
165165+ session, err := nr.refresher.GetSession(ctx, did)
161166 if err == nil {
162167 // OAuth session available - use indigo's API client (handles DPoP automatically)
163168 apiClient := session.APIClient()
···194199195200 // Create routing repository - routes manifests to ATProto, blobs to hold service
196201 // The registry is stateless - no local storage is used
197197- // Pass hold DID, user DID, authorizer, and refresher as parameters (can't use context as it gets lost)
198198- routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer, globalRefresher)
202202+ // Bundle all context into a single RegistryContext struct
203203+ registryCtx := &storage.RegistryContext{
204204+ DID: did,
205205+ HoldDID: holdDID,
206206+ PDSEndpoint: pdsEndpoint,
207207+ Repository: repositoryName,
208208+ ATProtoClient: atprotoClient,
209209+ Database: nr.database,
210210+ Authorizer: nr.authorizer,
211211+ Refresher: nr.refresher,
212212+ }
213213+ routingRepo := storage.NewRoutingRepository(repo, registryCtx)
199214200215 // Cache the repository
201216 nr.repositories.Store(cacheKey, routingRepo)
+29
pkg/appview/storage/context.go
···11+package storage
22+33+import (
44+ "atcr.io/pkg/atproto"
55+ "atcr.io/pkg/auth"
66+ "atcr.io/pkg/auth/oauth"
77+)
88+99+// DatabaseMetrics interface for tracking pull/push counts
1010+type DatabaseMetrics interface {
1111+ IncrementPullCount(did, repository string) error
1212+ IncrementPushCount(did, repository string) error
1313+}
1414+1515+// RegistryContext bundles all the context needed for registry operations
1616+// This includes both per-request data (DID, hold) and shared services
1717+type RegistryContext struct {
1818+ // Per-request identity and routing information
1919+ DID string // User's DID (e.g., "did:plc:abc123")
2020+ HoldDID string // Hold service DID (e.g., "did:web:hold01.atcr.io")
2121+ PDSEndpoint string // User's PDS endpoint URL
2222+ Repository string // Image repository name (e.g., "debian")
2323+ ATProtoClient *atproto.Client // Authenticated ATProto client for this user
2424+2525+ // Shared services (same for all requests)
2626+ Database DatabaseMetrics // Metrics tracking database
2727+ Authorizer auth.HoldAuthorizer // Hold access authorization
2828+ Refresher *oauth.Refresher // OAuth session manager
2929+}
+40-60
pkg/appview/storage/proxy_blob_store.go
···1111 "sync"
1212 "time"
13131414- "atcr.io/pkg/auth"
1515- "atcr.io/pkg/auth/oauth"
1614 "github.com/distribution/distribution/v3"
1715 "github.com/opencontainers/go-digest"
1816)
···32303331// ProxyBlobStore proxies blob requests to an external storage service
3432type ProxyBlobStore struct {
3535- holdDID string // Hold DID (e.g., "did:web:hold01.atcr.io")
3636- holdURL string // Resolved HTTP URL for XRPC requests
3333+ ctx *RegistryContext // All context and services
3434+ holdURL string // Resolved HTTP URL for XRPC requests
3735 httpClient *http.Client
3838- did string
3939- database DatabaseMetrics
4040- repository string
4141- authorizer auth.HoldAuthorizer
4242- refresher *oauth.Refresher // OAuth refresher for authenticating to hold service
4336}
44374538// NewProxyBlobStore creates a new proxy blob store
4646-func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer, refresher *oauth.Refresher) *ProxyBlobStore {
3939+func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
4740 // Resolve DID to URL once at construction time
4848- holdURL := resolveHoldURL(holdDID)
4141+ holdURL := resolveHoldURL(ctx.HoldDID)
49425043 fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n",
5151- holdDID, holdURL, did, repository)
4444+ ctx.HoldDID, holdURL, ctx.DID, ctx.Repository)
52455346 return &ProxyBlobStore{
5454- holdDID: holdDID,
4747+ ctx: ctx,
5548 holdURL: holdURL,
5649 httpClient: &http.Client{
5750 Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads
···6356 IdleConnTimeout: 90 * time.Second,
6457 },
6558 },
6666- did: did,
6767- database: database,
6868- repository: repository,
6969- authorizer: authorizer,
7070- refresher: refresher,
7159 }
7260}
7361···7664// Otherwise, uses the default httpClient without authentication
7765func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
7866 // Try to get OAuth session for DPoP authentication
7979- if p.refresher != nil {
8080- session, err := p.refresher.GetSession(ctx, p.did)
6767+ if p.ctx.Refresher != nil {
6868+ session, err := p.ctx.Refresher.GetSession(ctx, p.ctx.DID)
8169 if err != nil {
8282- fmt.Printf("DEBUG [proxy_blob_store]: Failed to get OAuth session for DID=%s: %v, will attempt without auth\n", p.did, err)
7070+ fmt.Printf("DEBUG [proxy_blob_store]: Failed to get OAuth session for DID=%s: %v, will attempt without auth\n", p.ctx.DID, err)
8371 } else {
8472 // Use session's DoWithAuth method (adds Authorization + DPoP headers)
8585- fmt.Printf("DEBUG [proxy_blob_store]: Using OAuth session for hold service request, DID=%s\n", p.did)
7373+ fmt.Printf("DEBUG [proxy_blob_store]: Using OAuth session for hold service request, DID=%s\n", p.ctx.DID)
8674 // The endpoint parameter is not used for DPoP signing, just token refresh validation
8775 // For hold service XRPC requests, we can pass "com.atproto.repo.uploadBlob"
8876 return session.DoWithAuth(session.Client, req, "com.atproto.repo.uploadBlob")
···9381 return p.httpClient.Do(req)
9482}
95839696-// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
9797-// did:web:hold01.atcr.io → https://hold01.atcr.io
9898-// did:web:172.28.0.3:8080 → http://172.28.0.3:8080
9999-func resolveHoldURL(holdDID string) string {
100100- hostname := strings.TrimPrefix(holdDID, "did:web:")
101101-102102- // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
103103- if strings.Contains(hostname, ":") ||
104104- strings.Contains(hostname, "127.0.0.1") ||
105105- strings.Contains(hostname, "localhost") ||
106106- // Check if it's an IP address (contains only digits and dots)
107107- (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
108108- return "http://" + hostname
109109- }
110110- return "https://" + hostname
111111-}
112112-113113-// checkReadAccess verifies the user has read access to the hold
8484+// checkReadAccess validates that the user has read access to blobs in this hold
11485func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error {
115115- if p.authorizer == nil {
116116- // No authorizer configured - allow access (backward compatibility)
117117- return nil
8686+ if p.ctx.Authorizer == nil {
8787+ return nil // No authorization check if authorizer not configured
11888 }
119119-120120- hasAccess, err := p.authorizer.CheckReadAccess(ctx, p.holdDID, p.did)
8989+ allowed, err := p.ctx.Authorizer.CheckReadAccess(ctx, p.ctx.HoldDID, p.ctx.DID)
12190 if err != nil {
12291 return fmt.Errorf("authorization check failed: %w", err)
12392 }
124124-125125- if !hasAccess {
9393+ if !allowed {
12694 return distribution.ErrBlobUnknown // Return same error as missing blob for security
12795 }
128128-12996 return nil
13097}
13198132132-// checkWriteAccess verifies the user has write access to the hold
9999+// checkWriteAccess validates that the user has write access to blobs in this hold
133100func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
134134- if p.authorizer == nil {
135135- // No authorizer configured - allow access (backward compatibility)
136136- return nil
101101+ if p.ctx.Authorizer == nil {
102102+ return nil // No authorization check if authorizer not configured
137103 }
138138-139139- hasAccess, err := p.authorizer.CheckWriteAccess(ctx, p.holdDID, p.did)
104104+ allowed, err := p.ctx.Authorizer.CheckWriteAccess(ctx, p.ctx.HoldDID, p.ctx.DID)
140105 if err != nil {
141106 return fmt.Errorf("authorization check failed: %w", err)
142107 }
143143-144144- if !hasAccess {
145145- return fmt.Errorf("write access denied to hold %s", p.holdDID)
108108+ if !allowed {
109109+ return fmt.Errorf("write access denied to hold %s", p.ctx.HoldDID)
146110 }
111111+ return nil
112112+}
147113148148- return nil
114114+// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
115115+// did:web:hold01.atcr.io → https://hold01.atcr.io
116116+// did:web:172.28.0.3:8080 → http://172.28.0.3:8080
117117+func resolveHoldURL(holdDID string) string {
118118+ hostname := strings.TrimPrefix(holdDID, "did:web:")
119119+120120+ // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
121121+ if strings.Contains(hostname, ":") ||
122122+ strings.Contains(hostname, "127.0.0.1") ||
123123+ strings.Contains(hostname, "localhost") ||
124124+ // Check if it's an IP address (contains only digits and dots)
125125+ (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
126126+ return "http://" + hostname
127127+ }
128128+ return "https://" + hostname
149129}
150130151131// Stat returns the descriptor for a blob
···390370 // Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid={digest}
391371 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
392372 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
393393- p.holdURL, p.holdDID, dgst.String())
373373+ p.holdURL, p.ctx.HoldDID, dgst.String())
394374 return url, nil
395375}
396376···399379func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) {
400380 // Same as GET - hold service handles HEAD method on getBlob endpoint
401381 url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s",
402402- p.holdURL, p.holdDID, dgst.String())
382382+ p.holdURL, p.ctx.HoldDID, dgst.String())
403383 return url, nil
404384}
405385
+29-47
pkg/appview/storage/routing_repository.go
···66 "time"
7788 "atcr.io/pkg/atproto"
99- "atcr.io/pkg/auth"
1010- "atcr.io/pkg/auth/oauth"
119 "github.com/distribution/distribution/v3"
1210)
13111414-// DatabaseMetrics interface for tracking pull/push counts
1515-type DatabaseMetrics interface {
1616- IncrementPullCount(did, repository string) error
1717- IncrementPushCount(did, repository string) error
1818-}
1919-2012// RoutingRepository routes manifests to ATProto and blobs to external hold service
2113// The registry (AppView) is stateless and NEVER stores blobs locally
2214type RoutingRepository struct {
2315 distribution.Repository
2424- atprotoClient *atproto.Client
2525- repositoryName string
2626- holdDID string // Hold service DID for blobs (from discovery for push), e.g., "did:web:hold01.atcr.io"
2727- did string // User's DID for authorization
2828- manifestStore *atproto.ManifestStore // Cached manifest store instance
2929- blobStore *ProxyBlobStore // Cached blob store instance
3030- database DatabaseMetrics // Database for metrics tracking
3131- authorizer auth.HoldAuthorizer // Authorization for hold access
3232- refresher *oauth.Refresher // OAuth refresher for authenticating to hold service
1616+ ctx *RegistryContext // All context and services
1717+ manifestStore *atproto.ManifestStore // Cached manifest store instance
1818+ blobStore *ProxyBlobStore // Cached blob store instance
3319}
34203521// NewRoutingRepository creates a new routing repository
3636-func NewRoutingRepository(
3737- baseRepo distribution.Repository,
3838- atprotoClient *atproto.Client,
3939- repoName string,
4040- holdDID string,
4141- did string,
4242- database DatabaseMetrics,
4343- authorizer auth.HoldAuthorizer,
4444- refresher *oauth.Refresher,
4545-) *RoutingRepository {
2222+func NewRoutingRepository(baseRepo distribution.Repository, ctx *RegistryContext) *RoutingRepository {
4623 return &RoutingRepository{
4747- Repository: baseRepo,
4848- atprotoClient: atprotoClient,
4949- repositoryName: repoName,
5050- holdDID: holdDID,
5151- did: did,
5252- database: database,
5353- authorizer: authorizer,
5454- refresher: refresher,
2424+ Repository: baseRepo,
2525+ ctx: ctx,
5526 }
5627}
5728···64356536 // ManifestStore needs both DID and URL for backward compat (legacy holdEndpoint field)
6637 // For now, pass holdDID twice (will be cleaned up in manifest_store.go later)
6767- r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.holdDID, r.holdDID, r.did, blobStore, r.database)
3838+ r.manifestStore = atproto.NewManifestStore(
3939+ r.ctx.ATProtoClient,
4040+ r.ctx.Repository,
4141+ r.ctx.HoldDID,
4242+ r.ctx.HoldDID,
4343+ r.ctx.DID,
4444+ blobStore,
4545+ r.ctx.Database,
4646+ )
6847 }
69487049 // After any manifest operation, cache the hold DID for blob fetches
···7352 time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete
7453 if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" {
7554 // Cache for 10 minutes - should cover typical pull operations
7676- GetGlobalHoldCache().Set(r.did, r.repositoryName, holdDID, 10*time.Minute)
5555+ GetGlobalHoldCache().Set(r.ctx.DID, r.ctx.Repository, holdDID, 10*time.Minute)
7756 fmt.Printf("DEBUG [storage/routing]: Cached hold DID: did=%s, repo=%s, hold=%s\n",
7878- r.did, r.repositoryName, holdDID)
5757+ r.ctx.DID, r.ctx.Repository, holdDID)
7958 }
8059 }()
8160···8867 // Return cached blob store if available
8968 if r.blobStore != nil {
9069 fmt.Printf("DEBUG [storage/blobs]: Returning cached blob store for did=%s, repo=%s\n",
9191- r.did, r.repositoryName)
7070+ r.ctx.DID, r.ctx.Repository)
9271 return r.blobStore
9372 }
94739574 // For pull operations, check if we have a cached hold DID from a recent manifest fetch
9675 // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered
9797- holdDID := r.holdDID // Default to discovery-based DID
7676+ holdDID := r.ctx.HoldDID // Default to discovery-based DID
98779999- if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok {
7878+ if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.ctx.DID, r.ctx.Repository); ok {
10079 // Use cached hold DID from manifest
10180 holdDID = cachedHoldDID
10281 fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n",
103103- r.did, r.repositoryName, cachedHoldDID)
8282+ r.ctx.DID, r.ctx.Repository, cachedHoldDID)
10483 } else {
10584 // No cached hold, use discovery-based DID (for push or first pull)
10685 fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n",
107107- r.did, r.repositoryName, holdDID)
8686+ r.ctx.DID, r.ctx.Repository, holdDID)
10887 }
1098811089 if holdDID == "" {
11190 // This should never happen if middleware is configured correctly
112112- panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware")
9191+ panic("hold DID not set in RegistryContext - ensure default_hold_did is configured in middleware")
11392 }
11493115115- // Create and cache proxy blob store with authorization and OAuth refresher
116116- r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer, r.refresher)
9494+ // Update context with the correct hold DID (may be cached or discovered)
9595+ r.ctx.HoldDID = holdDID
9696+9797+ // Create and cache proxy blob store
9898+ r.blobStore = NewProxyBlobStore(r.ctx)
11799 return r.blobStore
118100}
119101120102// Tags returns the tag service
121103// Tags are stored in ATProto as io.atcr.tag records
122104func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService {
123123- return atproto.NewTagStore(r.atprotoClient, r.repositoryName)
105105+ return atproto.NewTagStore(r.ctx.ATProtoClient, r.ctx.Repository)
124106}