fix all the places where did used to be an endpoint

This commit is contained in:
Evan Jarrett
2025-10-17 20:40:04 -05:00
parent 606c8a842a
commit 80b65ee619
12 changed files with 169 additions and 185 deletions
+6 -4
View File
@@ -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
+7 -7
View File
@@ -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,
},
},
},
+16 -20
View File
@@ -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
}
}
}
+7
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+31 -33
View File
@@ -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
+9 -9
View File
@@ -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)
+39 -21
View File
@@ -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
+35 -36
View File
@@ -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
}
+16 -35
View File
@@ -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
}
+1 -18
View File
@@ -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 = `