diff --git a/.env.appview.example b/.env.appview.example index f5ce069..398da9e 100644 --- a/.env.appview.example +++ b/.env.appview.example @@ -26,11 +26,13 @@ ATCR_HTTP_ADDR=:5000 # Storage Configuration # ============================================================================== -# Default hold service endpoint for users without their own storage (REQUIRED) +# Default hold service DID for users without their own storage (REQUIRED) # Users with a sailor profile defaultHold setting will override this -# Docker: Use container name (http://atcr-hold:8080) -# Local dev: Use localhost (http://127.0.0.1:8080) -ATCR_DEFAULT_HOLD=http://127.0.0.1:8080 +# Format: did:web:hostname[:port] +# Docker: did:web:atcr-hold:8080 +# Local dev: did:web:127.0.0.1:8080 +# Production: did:web:hold01.atcr.io +ATCR_DEFAULT_HOLD_DID=did:web:127.0.0.1:8080 # ============================================================================== # Authentication Configuration diff --git a/cmd/appview/config.go b/cmd/appview/config.go index cc8a99f..9097f8b 100644 --- a/cmd/appview/config.go +++ b/cmd/appview/config.go @@ -34,11 +34,11 @@ func loadConfigFromEnv() (*configuration.Configuration, error) { config.Storage = buildStorageConfig() // Middleware (ATProto resolver) - defaultHold := os.Getenv("ATCR_DEFAULT_HOLD") - if defaultHold == "" { - return nil, fmt.Errorf("ATCR_DEFAULT_HOLD is required") + defaultHoldDID := os.Getenv("ATCR_DEFAULT_HOLD_DID") + if defaultHoldDID == "" { + return nil, fmt.Errorf("ATCR_DEFAULT_HOLD_DID is required") } - config.Middleware = buildMiddlewareConfig(defaultHold) + config.Middleware = buildMiddlewareConfig(defaultHoldDID) // Auth baseURL := getBaseURL(httpConfig.Addr) @@ -123,7 +123,7 @@ func buildStorageConfig() configuration.Storage { } // buildMiddlewareConfig creates middleware configuration -func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middleware { +func buildMiddlewareConfig(defaultHoldDID string) map[string][]configuration.Middleware { // Check test mode testMode := os.Getenv("TEST_MODE") == "true" @@ -132,8 +132,8 @@ func buildMiddlewareConfig(defaultHold string) map[string][]configuration.Middle { Name: "atproto-resolver", Options: configuration.Parameters{ - "default_storage_endpoint": defaultHold, - "test_mode": testMode, + "default_hold_did": defaultHoldDID, + "test_mode": testMode, }, }, }, diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 20097be..eeeb00b 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -20,7 +20,6 @@ import ( "github.com/spf13/cobra" "atcr.io/pkg/appview/middleware" - "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "atcr.io/pkg/auth/oauth" "atcr.io/pkg/auth/token" @@ -110,15 +109,15 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Initialize OAuth components fmt.Println("Initializing OAuth components...") - // 1. Create OAuth session storage (SQLite-backed) + // Create OAuth session storage (SQLite-backed) oauthStore := db.NewOAuthStore(uiDatabase) fmt.Println("Using SQLite for OAuth session storage") - // 2. Create device store (SQLite-backed) + // Create device store (SQLite-backed) deviceStore := db.NewDeviceStore(uiDatabase) fmt.Println("Using SQLite for device storage") - // 3. Get base URL from config or environment + // Get base URL from config or environment baseURL := os.Getenv("ATCR_BASE_URL") if baseURL == "" { // If addr is just a port (e.g., ":5000"), prepend localhost @@ -132,24 +131,24 @@ func serveRegistry(cmd *cobra.Command, args []string) error { fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) - // 4. Create OAuth app (indigo client) + // Create OAuth app (indigo client) oauthApp, err := oauth.NewApp(baseURL, oauthStore) if err != nil { return fmt.Errorf("failed to create OAuth app: %w", err) } fmt.Println("Using full OAuth scopes (including blob: scope)") - // 5. Create refresher + // Create oauth token refresher refresher := oauth.NewRefresher(oauthApp) - // 6. Set global refresher for middleware + // Set global refresher for middleware middleware.SetGlobalRefresher(refresher) - // 6.5. Set global database for pull/push metrics tracking + // Set global database for pull/push metrics tracking metricsDB := db.NewMetricsDB(uiDatabase) middleware.SetGlobalDatabase(metricsDB) - // 6.6. Create RemoteHoldAuthorizer for hold authorization with caching + // Create RemoteHoldAuthorizer for hold authorization with caching holdAuthorizer := auth.NewRemoteHoldAuthorizer(uiDatabase) middleware.SetGlobalAuthorizer(holdAuthorizer) fmt.Println("Hold authorizer initialized with database caching") @@ -161,10 +160,10 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // The extraction function normalizes URLs to DIDs for consistency defaultHoldDID := extractDefaultHoldDID(config) - // 7. Initialize UI routes with OAuth app, refresher, and device store + // Initialize UI routes with OAuth app, refresher, and device store uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID) - // 8. Create OAuth server + // Create OAuth server oauthServer := oauth.NewServer(oauthApp) // Connect server to refresher for cache invalidation oauthServer.SetRefresher(refresher) @@ -175,14 +174,14 @@ func serveRegistry(cmd *cobra.Command, args []string) error { // Connect database for user avatar management oauthServer.SetDatabase(uiDatabase) - // 8.5. Set default hold DID on OAuth server (extracted earlier) + // Set default hold DID on OAuth server (extracted earlier) // This is used to create sailor profiles on first login if defaultHoldDID != "" { oauthServer.SetDefaultHoldDID(defaultHoldDID) fmt.Printf("OAuth server will create profiles with default hold: %s\n", defaultHoldDID) } - // 9. Initialize auth keys and create token issuer + // Initialize auth keys and create token issuer var issuer *token.Issuer if config.Auth["token"] != nil { if err := initializeAuthKeys(config); err != nil { @@ -365,11 +364,10 @@ func getIntParam(params configuration.Parameters, key string, defaultValue int) } // extractDefaultHoldDID extracts the default hold DID from middleware config -// Returns a DID (e.g., "did:web:hold01.atcr.io") for consistency -// Accepts both DIDs and URLs in config for backward compatibility +// Returns a DID (e.g., "did:web:hold01.atcr.io") // To find a hold's DID, visit: https://hold-url/.well-known/did.json func extractDefaultHoldDID(config *configuration.Configuration) string { - // Navigate through: middleware.registry[].options.default_storage_endpoint + // Navigate through: middleware.registry[].options.default_hold_did registryMiddleware, ok := config.Middleware["registry"] if !ok { return "" @@ -384,10 +382,8 @@ func extractDefaultHoldDID(config *configuration.Configuration) string { // Extract options - options is configuration.Parameters which is map[string]any if mw.Options != nil { - if endpoint, ok := mw.Options["default_storage_endpoint"].(string); ok { - // Normalize to DID (handles both URLs and DIDs) - // This ensures we store DIDs consistently - return atproto.ResolveHoldDIDFromURL(endpoint) + if holdDID, ok := mw.Options["default_hold_did"].(string); ok { + return holdDID } } } diff --git a/deploy/.env.prod.template b/deploy/.env.prod.template index a14a4ea..ee97531 100644 --- a/deploy/.env.prod.template +++ b/deploy/.env.prod.template @@ -126,6 +126,13 @@ S3_ENDPOINT=https://6vmss.upcloudobjects.com # AppView Configuration # ============================================================================== +# Default hold service DID (REQUIRED) +# This is automatically set by docker-compose.prod.yml to did:web:${HOLD_DOMAIN} +# Only override this if you want to use a different default hold +# Format: did:web:hostname[:port] +# Example: did:web:hold01.atcr.io +# Note: This is set automatically - no need to configure manually + # JWT token expiration in seconds # Default: 300 (5 minutes) ATCR_TOKEN_EXPIRATION=300 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index d6f3287..6129bfd 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -51,7 +51,7 @@ services: ATCR_SERVICE_NAME: ${APPVIEW_DOMAIN:-atcr.io} # Storage configuration - ATCR_DEFAULT_HOLD: https://${HOLD_DOMAIN:-hold01.atcr.io} + ATCR_DEFAULT_HOLD_DID: did:web:${HOLD_DOMAIN:-hold01.atcr.io} # Authentication ATCR_AUTH_KEY_PATH: /var/lib/atcr/auth/private-key.pem diff --git a/docker-compose.yml b/docker-compose.yml index 4bf5b5e..81080e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: environment: # Server configuration ATCR_HTTP_ADDR: :5000 - ATCR_DEFAULT_HOLD: http://172.28.0.3:8080 + ATCR_DEFAULT_HOLD_DID: did:web:172.28.0.3:8080 # UI configuration ATCR_UI_ENABLED: true ATCR_BACKFILL_ENABLED: true diff --git a/pkg/appview/middleware/registry.go b/pkg/appview/middleware/registry.go index 630f385..4113298 100644 --- a/pkg/appview/middleware/registry.go +++ b/pkg/appview/middleware/registry.go @@ -58,10 +58,10 @@ func init() { // NamespaceResolver wraps a namespace and resolves names type NamespaceResolver struct { distribution.Namespace - directory identity.Directory - defaultStorageEndpoint string - testMode bool // If true, fallback to default hold when user's hold is unreachable - repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) + directory identity.Directory + defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io") + testMode bool // If true, fallback to default hold when user's hold is unreachable + repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame) } // initATProtoResolver initializes the name resolution middleware @@ -69,12 +69,11 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive // Use indigo's default directory (includes caching) directory := identity.DefaultDirectory() - // Get default storage endpoint from config (optional) - // Normalize to DID format for consistency - defaultStorageEndpoint := "" - if endpoint, ok := options["default_storage_endpoint"].(string); ok { - // Convert URL to DID if needed (or pass through if already a DID) - defaultStorageEndpoint = atproto.ResolveHoldDIDFromURL(endpoint) + // Get default hold DID from config (required) + // Expected format: "did:web:hold01.atcr.io" + defaultHoldDID := "" + if holdDID, ok := options["default_hold_did"].(string); ok { + defaultHoldDID = holdDID } // Check test mode from options (passed via env var) @@ -84,10 +83,10 @@ func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ drive } return &NamespaceResolver{ - Namespace: ns, - directory: directory, - defaultStorageEndpoint: defaultStorageEndpoint, - testMode: testMode, + Namespace: ns, + directory: directory, + defaultHoldDID: defaultHoldDID, + testMode: testMode, }, nil } @@ -128,13 +127,13 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name fmt.Printf("DEBUG [registry/middleware]: Resolved identity: did=%s, pds=%s, handle=%s\n", did, pdsEndpoint, ident.Handle.String()) - // Query for storage endpoint - either user's hold or default hold service - storageEndpoint := nr.findStorageEndpoint(ctx, did, pdsEndpoint) - if storageEndpoint == "" { + // Query for hold DID - either user's hold or default hold service + holdDID := nr.findHoldDID(ctx, did, pdsEndpoint) + if holdDID == "" { // This is a fatal configuration error - registry cannot function without a hold service - return nil, fmt.Errorf("no storage endpoint configured: ensure default_storage_endpoint is set in middleware config") + return nil, fmt.Errorf("no hold DID configured: ensure default_hold_did is set in middleware config") } - ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint) + ctx = context.WithValue(ctx, "hold.did", holdDID) // Create a new reference with identity/image format // Use the identity (or DID) as the namespace to ensure canonical format @@ -195,8 +194,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name // Create routing repository - routes manifests to ATProto, blobs to hold service // The registry is stateless - no local storage is used - // Pass storage endpoint, DID, and authorizer as parameters (can't use context as it gets lost) - routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase, globalAuthorizer) + // Pass hold DID, user DID, and authorizer as parameters (can't use context as it gets lost) + routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer) // Cache the repository nr.repositories.Store(cacheKey, routingRepo) @@ -219,18 +218,17 @@ func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter { return nr.Namespace.BlobStatter() } -// findStorageEndpoint determines which hold endpoint to use for blob storage +// findHoldDID determines which hold DID to use for blob storage // Priority order: // 1. User's sailor profile defaultHold (if set) // 2. User's own hold record (io.atcr.hold) -// 3. AppView's default hold endpoint +// 3. AppView's default hold DID // Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured -// Note: Despite returning a DID, this is used as the "storage endpoint" throughout the code -func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEndpoint string) string { +func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string { // Create ATProto client (without auth - reading public records) client := atproto.NewClient(pdsEndpoint, did, "") - // 1. Check for sailor profile + // Check for sailor profile profile, err := atproto.GetProfile(ctx, client) if err != nil { // Error reading profile (not a 404) - log and continue @@ -245,17 +243,17 @@ func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEn return profile.DefaultHold } fmt.Printf("DEBUG [registry/middleware/testmode]: User's defaultHold %s unreachable, falling back to default\n", profile.DefaultHold) - return nr.defaultStorageEndpoint + return nr.defaultHoldDID } return profile.DefaultHold } - // 2. Profile doesn't exist or defaultHold is null/empty + // Profile doesn't exist or defaultHold is null/empty // Check for user's own hold records records, err := client.ListRecords(ctx, atproto.HoldCollection, 10) if err != nil { // Failed to query holds, use default - return nr.defaultStorageEndpoint + return nr.defaultHoldDID } // Find the first hold record @@ -265,14 +263,14 @@ func (nr *NamespaceResolver) findStorageEndpoint(ctx context.Context, did, pdsEn continue } - // Return the endpoint from the first hold + // Return the endpoint from the first hold (normalize to DID if URL) if holdRecord.Endpoint != "" { - return holdRecord.Endpoint + return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint) } } - // 3. No profile defaultHold and no own hold records - use AppView default - return nr.defaultStorageEndpoint + // No profile defaultHold and no own hold records - use AppView default + return nr.defaultHoldDID } // isHoldReachable checks if a hold service is reachable diff --git a/pkg/appview/storage/hold_cache.go b/pkg/appview/storage/hold_cache.go index c1daa62..60a6100 100644 --- a/pkg/appview/storage/hold_cache.go +++ b/pkg/appview/storage/hold_cache.go @@ -5,7 +5,7 @@ import ( "time" ) -// HoldCache caches hold endpoints for (DID, repository) pairs +// HoldCache caches hold DIDs for (DID, repository) pairs // This avoids expensive ATProto lookups on every blob request during pulls // // NOTE: This is a simple in-memory cache for MVP. For production deployments: @@ -18,8 +18,8 @@ type HoldCache struct { } type holdCacheEntry struct { - holdEndpoint string - expiresAt time.Time + holdDID string + expiresAt time.Time } var globalHoldCache = &HoldCache{ @@ -42,19 +42,19 @@ func GetGlobalHoldCache() *HoldCache { return globalHoldCache } -// Set stores a hold endpoint for a (DID, repository) pair with a TTL -func (c *HoldCache) Set(did, repository, holdEndpoint string, ttl time.Duration) { +// Set stores a hold DID for a (DID, repository) pair with a TTL +func (c *HoldCache) Set(did, repository, holdDID string, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() key := did + ":" + repository c.cache[key] = &holdCacheEntry{ - holdEndpoint: holdEndpoint, - expiresAt: time.Now().Add(ttl), + holdDID: holdDID, + expiresAt: time.Now().Add(ttl), } } -// Get retrieves a hold endpoint for a (DID, repository) pair +// Get retrieves a hold DID for a (DID, repository) pair // Returns empty string and false if not found or expired func (c *HoldCache) Get(did, repository string) (string, bool) { c.mu.RLock() @@ -72,7 +72,7 @@ func (c *HoldCache) Get(did, repository string) (string, bool) { return "", false } - return entry.holdEndpoint, true + return entry.holdDID, true } // Cleanup removes expired entries (called automatically every 5 minutes) diff --git a/pkg/appview/storage/proxy_blob_store.go b/pkg/appview/storage/proxy_blob_store.go index 1b3a2a1..bf282c0 100644 --- a/pkg/appview/storage/proxy_blob_store.go +++ b/pkg/appview/storage/proxy_blob_store.go @@ -7,10 +7,10 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" - "atcr.io/pkg/atproto" "atcr.io/pkg/auth" "github.com/distribution/distribution/v3" "github.com/opencontainers/go-digest" @@ -31,24 +31,26 @@ var ( // ProxyBlobStore proxies blob requests to an external storage service type ProxyBlobStore struct { - storageEndpoint string - httpClient *http.Client - did string - database DatabaseMetrics - repository string - authorizer auth.HoldAuthorizer - holdDID string + holdDID string // Hold DID (e.g., "did:web:hold01.atcr.io") + holdURL string // Resolved HTTP URL for XRPC requests + httpClient *http.Client + did string + database DatabaseMetrics + repository string + authorizer auth.HoldAuthorizer } // NewProxyBlobStore creates a new proxy blob store -func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore { - // Convert storage endpoint URL to did:web DID for authorization - holdDID := atproto.ResolveHoldDIDFromURL(storageEndpoint) - fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with endpoint=%s, holdDID=%s, userDID=%s, repo=%s\n", - storageEndpoint, holdDID, did, repository) +func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore { + // Resolve DID to URL once at construction time + holdURL := resolveHoldURL(holdDID) + + fmt.Printf("DEBUG [proxy_blob_store]: NewProxyBlobStore created with holdDID=%s, holdURL=%s, userDID=%s, repo=%s\n", + holdDID, holdURL, did, repository) return &ProxyBlobStore{ - storageEndpoint: storageEndpoint, + holdDID: holdDID, + holdURL: holdURL, httpClient: &http.Client{ Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads Transport: &http.Transport{ @@ -63,10 +65,26 @@ func NewProxyBlobStore(storageEndpoint, did string, database DatabaseMetrics, re database: database, repository: repository, authorizer: authorizer, - holdDID: holdDID, } } +// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests +// did:web:hold01.atcr.io → https://hold01.atcr.io +// did:web:172.28.0.3:8080 → http://172.28.0.3:8080 +func resolveHoldURL(holdDID string) string { + hostname := strings.TrimPrefix(holdDID, "did:web:") + + // Use HTTP for localhost/IP addresses with ports, HTTPS for domains + if strings.Contains(hostname, ":") || + strings.Contains(hostname, "127.0.0.1") || + strings.Contains(hostname, "localhost") || + // Check if it's an IP address (contains only digits and dots) + (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) { + return "http://" + hostname + } + return "https://" + hostname +} + // checkReadAccess verifies the user has read access to the hold func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error { if p.authorizer == nil { @@ -347,7 +365,7 @@ func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) // Use XRPC endpoint: GET /xrpc/com.atproto.sync.getBlob?did={holdDID}&cid={digest} // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix) url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", - p.storageEndpoint, p.holdDID, dgst.String()) + p.holdURL, p.holdDID, dgst.String()) return url, nil } @@ -356,7 +374,7 @@ func (p *ProxyBlobStore) getDownloadURL(ctx context.Context, dgst digest.Digest) func (p *ProxyBlobStore) getHeadURL(ctx context.Context, dgst digest.Digest) (string, error) { // Same as GET - hold service handles HEAD method on getBlob endpoint url := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", - p.storageEndpoint, p.holdDID, dgst.String()) + p.holdURL, p.holdDID, dgst.String()) return url, nil } @@ -378,7 +396,7 @@ func (p *ProxyBlobStore) startMultipartUpload(ctx context.Context, digest string return "", err } - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return "", err @@ -428,7 +446,7 @@ func (p *ProxyBlobStore) getPartUploadInfo(ctx context.Context, digest, uploadID return nil, err } - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return nil, err @@ -478,7 +496,7 @@ func (p *ProxyBlobStore) completeMultipartUpload(ctx context.Context, digest, up return err } - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return err @@ -512,7 +530,7 @@ func (p *ProxyBlobStore) abortMultipartUpload(ctx context.Context, digest, uploa return err } - url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.storageEndpoint) + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", p.holdURL) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return err diff --git a/pkg/appview/storage/routing_repository.go b/pkg/appview/storage/routing_repository.go index c8b1b39..a591709 100644 --- a/pkg/appview/storage/routing_repository.go +++ b/pkg/appview/storage/routing_repository.go @@ -20,14 +20,14 @@ type DatabaseMetrics interface { // The registry (AppView) is stateless and NEVER stores blobs locally type RoutingRepository struct { distribution.Repository - atprotoClient *atproto.Client - repositoryName string - storageEndpoint string // Hold service endpoint for blobs (from discovery for push) - did string // User's DID for authorization - manifestStore *atproto.ManifestStore // Cached manifest store instance - blobStore *ProxyBlobStore // Cached blob store instance - database DatabaseMetrics // Database for metrics tracking - authorizer auth.HoldAuthorizer // Authorization for hold access + atprotoClient *atproto.Client + repositoryName string + holdDID string // Hold service DID for blobs (from discovery for push), e.g., "did:web:hold01.atcr.io" + did string // User's DID for authorization + manifestStore *atproto.ManifestStore // Cached manifest store instance + blobStore *ProxyBlobStore // Cached blob store instance + database DatabaseMetrics // Database for metrics tracking + authorizer auth.HoldAuthorizer // Authorization for hold access } // NewRoutingRepository creates a new routing repository @@ -35,19 +35,19 @@ func NewRoutingRepository( baseRepo distribution.Repository, atprotoClient *atproto.Client, repoName string, - storageEndpoint string, + holdDID string, did string, database DatabaseMetrics, authorizer auth.HoldAuthorizer, ) *RoutingRepository { return &RoutingRepository{ - Repository: baseRepo, - atprotoClient: atprotoClient, - repositoryName: repoName, - storageEndpoint: storageEndpoint, - did: did, - database: database, - authorizer: authorizer, + Repository: baseRepo, + atprotoClient: atprotoClient, + repositoryName: repoName, + holdDID: holdDID, + did: did, + database: database, + authorizer: authorizer, } } @@ -58,21 +58,20 @@ func (r *RoutingRepository) Manifests(ctx context.Context, options ...distributi // Ensure blob store is created first (needed for label extraction during push) blobStore := r.Blobs(ctx) - // Resolve hold endpoint URL to DID - holdDID := atproto.ResolveHoldDIDFromURL(r.storageEndpoint) - - r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.storageEndpoint, holdDID, r.did, blobStore, r.database) + // ManifestStore needs both DID and URL for backward compat (legacy holdEndpoint field) + // For now, pass holdDID twice (will be cleaned up in manifest_store.go later) + r.manifestStore = atproto.NewManifestStore(r.atprotoClient, r.repositoryName, r.holdDID, r.holdDID, r.did, blobStore, r.database) } - // After any manifest operation, cache the hold endpoint for blob fetches + // After any manifest operation, cache the hold DID for blob fetches // We use a goroutine to avoid blocking, and check after a short delay to allow the operation to complete go func() { time.Sleep(100 * time.Millisecond) // Brief delay to let manifest fetch complete - if holdEndpoint := r.manifestStore.GetLastFetchedHoldEndpoint(); holdEndpoint != "" { + if holdDID := r.manifestStore.GetLastFetchedHoldDID(); holdDID != "" { // Cache for 10 minutes - should cover typical pull operations - GetGlobalHoldCache().Set(r.did, r.repositoryName, holdEndpoint, 10*time.Minute) - fmt.Printf("DEBUG [storage/routing]: Cached hold endpoint: did=%s, repo=%s, hold=%s\n", - r.did, r.repositoryName, holdEndpoint) + GetGlobalHoldCache().Set(r.did, r.repositoryName, holdDID, 10*time.Minute) + fmt.Printf("DEBUG [storage/routing]: Cached hold DID: did=%s, repo=%s, hold=%s\n", + r.did, r.repositoryName, holdDID) } }() @@ -89,28 +88,28 @@ func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore { return r.blobStore } - // For pull operations, check if we have a cached hold endpoint from a recent manifest fetch + // For pull operations, check if we have a cached hold DID from a recent manifest fetch // This ensures blobs are fetched from the hold recorded in the manifest, not re-discovered - holdEndpoint := r.storageEndpoint // Default to discovery-based endpoint + holdDID := r.holdDID // Default to discovery-based DID - if cachedHold, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok { - // Use cached hold from manifest - holdEndpoint = cachedHold + if cachedHoldDID, ok := GetGlobalHoldCache().Get(r.did, r.repositoryName); ok { + // Use cached hold DID from manifest + holdDID = cachedHoldDID fmt.Printf("DEBUG [storage/blobs]: Using cached hold from manifest: did=%s, repo=%s, hold=%s\n", - r.did, r.repositoryName, cachedHold) + r.did, r.repositoryName, cachedHoldDID) } else { - // No cached hold, use discovery-based endpoint (for push or first pull) + // No cached hold, use discovery-based DID (for push or first pull) fmt.Printf("DEBUG [storage/blobs]: Using discovery-based hold: did=%s, repo=%s, hold=%s\n", - r.did, r.repositoryName, holdEndpoint) + r.did, r.repositoryName, holdDID) } - if holdEndpoint == "" { + if holdDID == "" { // This should never happen if middleware is configured correctly - panic("storage endpoint not set in RoutingRepository - ensure default_storage_endpoint is configured in middleware") + panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware") } // Create and cache proxy blob store with authorization - r.blobStore = NewProxyBlobStore(holdEndpoint, r.did, r.database, r.repositoryName, r.authorizer) + r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer) return r.blobStore } diff --git a/pkg/atproto/manifest_store.go b/pkg/atproto/manifest_store.go index 73a6f41..f4f048e 100644 --- a/pkg/atproto/manifest_store.go +++ b/pkg/atproto/manifest_store.go @@ -21,14 +21,14 @@ type DatabaseMetrics interface { // ManifestStore implements distribution.ManifestService // It stores manifests in ATProto as records type ManifestStore struct { - client *Client - repository string - holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) - holdDID string // Hold service DID (primary reference) - did string // User's DID for cache key - lastFetchedHoldEndpoint string // Hold endpoint from most recently fetched manifest (for pull) - blobStore distribution.BlobStore // Blob store for fetching config during push - database DatabaseMetrics // Database for metrics tracking + client *Client + repository string + holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated) + holdDID string // Hold service DID (primary reference) + did string // User's DID for cache key + lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) + blobStore distribution.BlobStore // Blob store for fetching config during push + database DatabaseMetrics // Database for metrics tracking } // NewManifestStore creates a new ATProto-backed manifest store @@ -74,23 +74,15 @@ func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ... return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err) } - // Store the hold endpoint for subsequent blob requests during pull + // Store the hold DID for subsequent blob requests during pull // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format) // The routing repository will cache this for concurrent blob fetches if manifestRecord.HoldDID != "" { - // New format: DID reference - // Convert did:web back to URL for blob fetching - // TODO: Routing repository should handle DID→URL conversion - // For now, fall back to HoldEndpoint if available - if manifestRecord.HoldEndpoint != "" { - s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint - } else { - // Convert did:web:hold.example.com → https://hold.example.com - s.lastFetchedHoldEndpoint = didToURL(manifestRecord.HoldDID) - } + // New format: DID reference (preferred) + s.lastFetchedHoldDID = manifestRecord.HoldDID } else if manifestRecord.HoldEndpoint != "" { - // Legacy format: URL reference - s.lastFetchedHoldEndpoint = manifestRecord.HoldEndpoint + // Legacy format: URL reference - convert to DID + s.lastFetchedHoldDID = ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint) } var ociManifest []byte @@ -246,10 +238,10 @@ func RKeyToRepositoryTag(rkey string) (repository, tag string) { return repository, tag } -// GetLastFetchedHoldEndpoint returns the hold endpoint from the most recently fetched manifest +// GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest // This is used by the routing repository to cache the hold for blob requests -func (s *ManifestStore) GetLastFetchedHoldEndpoint() string { - return s.lastFetchedHoldEndpoint +func (s *ManifestStore) GetLastFetchedHoldDID() string { + return s.lastFetchedHoldDID } // rawManifest is a simple implementation of distribution.Manifest @@ -294,14 +286,3 @@ func (s *ManifestStore) extractConfigLabels(ctx context.Context, configDigestStr return configJSON.Config.Labels, nil } - -// didToURL converts a did:web DID to an HTTPS URL -// e.g., did:web:hold.example.com → https://hold.example.com -func didToURL(didWeb string) string { - if !strings.HasPrefix(didWeb, "did:web:") { - return didWeb // Not a did:web, return as-is - } - - hostname := strings.TrimPrefix(didWeb, "did:web:") - return "https://" + hostname -} diff --git a/pkg/auth/oauth/server.go b/pkg/auth/oauth/server.go index 046fc72..4082934 100644 --- a/pkg/auth/oauth/server.go +++ b/pkg/auth/oauth/server.go @@ -342,7 +342,7 @@ func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atpr fmt.Printf("DEBUG [oauth/server]: Migrating hold URL to DID for %s: %s\n", did, profile.DefaultHold) // Resolve URL to DID - holdDID = resolveHoldDIDFromURL(profile.DefaultHold) + holdDID = atproto.ResolveHoldDIDFromURL(profile.DefaultHold) // Update profile with DID profile.DefaultHold = holdDID @@ -364,23 +364,6 @@ func (s *Server) migrateProfileAndRegisterCrew(ctx context.Context, client *atpr _ = session // TODO: use session for crew registration } -// resolveHoldDIDFromURL converts a hold endpoint URL to a DID -// For did:web holds: https://hold01.atcr.io → did:web:hold01.atcr.io -func resolveHoldDIDFromURL(holdURL string) string { - // Parse URL to get hostname - holdURL = strings.TrimPrefix(holdURL, "http://") - holdURL = strings.TrimPrefix(holdURL, "https://") - holdURL = strings.TrimSuffix(holdURL, "/") - - // Extract hostname (remove path if present) - parts := strings.Split(holdURL, "/") - hostname := parts[0] - - // Convert to did:web - // did:web uses hostname directly (port included if non-standard) - return "did:web:" + hostname -} - // HTML templates const redirectToSettingsTemplate = `