mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 00:06:58 +00:00
fix all the places where did used to be an endpoint
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
Reference in New Issue
Block a user