mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
250 lines
8.7 KiB
Go
250 lines
8.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
"github.com/distribution/distribution/v3"
|
|
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
|
|
"github.com/distribution/distribution/v3/registry/storage/driver"
|
|
"github.com/distribution/reference"
|
|
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
"atcr.io/pkg/auth/oauth"
|
|
)
|
|
|
|
// Global refresher instance (set by main.go)
|
|
var globalRefresher *oauth.Refresher
|
|
|
|
// Global database instance (set by main.go for pull tracking)
|
|
var globalDatabase interface {
|
|
IncrementPullCount(did, repository string) error
|
|
IncrementPushCount(did, repository string) error
|
|
}
|
|
|
|
// SetGlobalRefresher sets the global OAuth refresher instance
|
|
func SetGlobalRefresher(refresher *oauth.Refresher) {
|
|
globalRefresher = refresher
|
|
}
|
|
|
|
// SetGlobalDatabase sets the global database instance for metrics tracking
|
|
func SetGlobalDatabase(database interface {
|
|
IncrementPullCount(did, repository string) error
|
|
IncrementPushCount(did, repository string) error
|
|
}) {
|
|
globalDatabase = database
|
|
}
|
|
|
|
func init() {
|
|
// Register the name resolution middleware
|
|
registrymw.Register("atproto-resolver", initATProtoResolver)
|
|
}
|
|
|
|
// NamespaceResolver wraps a namespace and resolves names
|
|
type NamespaceResolver struct {
|
|
distribution.Namespace
|
|
directory identity.Directory
|
|
defaultStorageEndpoint string
|
|
repositories sync.Map // Cache of RoutingRepository instances by key (did:reponame)
|
|
}
|
|
|
|
// initATProtoResolver initializes the name resolution middleware
|
|
func initATProtoResolver(ctx context.Context, ns distribution.Namespace, _ driver.StorageDriver, options map[string]any) (distribution.Namespace, error) {
|
|
// Use indigo's default directory (includes caching)
|
|
directory := identity.DefaultDirectory()
|
|
|
|
// Get default storage endpoint from config (optional)
|
|
defaultStorageEndpoint := ""
|
|
if endpoint, ok := options["default_storage_endpoint"].(string); ok {
|
|
defaultStorageEndpoint = endpoint
|
|
}
|
|
|
|
return &NamespaceResolver{
|
|
Namespace: ns,
|
|
directory: directory,
|
|
defaultStorageEndpoint: defaultStorageEndpoint,
|
|
}, nil
|
|
}
|
|
|
|
// Repository resolves the repository name and delegates to underlying namespace
|
|
// Handles names like:
|
|
// - atcr.io/alice/myimage → resolve alice to DID
|
|
// - atcr.io/did:plc:xyz123/myimage → use DID directly
|
|
func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Named) (distribution.Repository, error) {
|
|
// Extract the first part of the name (username or DID)
|
|
repoPath := name.Name()
|
|
parts := strings.SplitN(repoPath, "/", 2)
|
|
|
|
if len(parts) < 2 {
|
|
// No user specified, use default or return error
|
|
return nil, fmt.Errorf("repository name must include user: %s", repoPath)
|
|
}
|
|
|
|
identityStr := parts[0]
|
|
imageName := parts[1]
|
|
|
|
// Parse identity (handle or DID)
|
|
atID, err := syntax.ParseAtIdentifier(identityStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid identity %s: %w", identityStr, err)
|
|
}
|
|
|
|
// Resolve identity to DID and PDS using indigo's directory
|
|
ident, err := nr.directory.Lookup(ctx, *atID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve identity %s: %w", identityStr, err)
|
|
}
|
|
|
|
did := ident.DID.String()
|
|
pdsEndpoint := ident.PDSEndpoint()
|
|
if pdsEndpoint == "" {
|
|
return nil, fmt.Errorf("no PDS endpoint found for %s", identityStr)
|
|
}
|
|
|
|
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 == "" {
|
|
// 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")
|
|
}
|
|
ctx = context.WithValue(ctx, "storage.endpoint", storageEndpoint)
|
|
|
|
// Create a new reference with identity/image format
|
|
// Use the identity (or DID) as the namespace to ensure canonical format
|
|
// This transforms: evan.jarrett.net/debian -> evan.jarrett.net/debian (keeps full path)
|
|
canonicalName := fmt.Sprintf("%s/%s", identityStr, imageName)
|
|
ref, err := reference.ParseNamed(canonicalName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid image name %s: %w", imageName, err)
|
|
}
|
|
|
|
// Delegate to underlying namespace with modified name
|
|
repo, err := nr.Namespace.Repository(ctx, ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get access token for PDS operations
|
|
// Try OAuth refresher first (for users who authorized via AppView OAuth)
|
|
// Fall back to Basic Auth token cache (for users who used app passwords)
|
|
var atprotoClient *atproto.Client
|
|
|
|
if globalRefresher != nil {
|
|
// Try OAuth flow first
|
|
session, err := globalRefresher.GetSession(ctx, did)
|
|
if err == nil {
|
|
// OAuth session available - use indigo's API client (handles DPoP automatically)
|
|
apiClient := session.APIClient()
|
|
atprotoClient = atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
|
|
} else {
|
|
fmt.Printf("DEBUG [registry/middleware]: OAuth refresh failed for DID=%s: %v, falling back to Basic Auth\n", did, err)
|
|
}
|
|
}
|
|
|
|
// Fall back to Basic Auth token cache if OAuth not available
|
|
if atprotoClient == nil {
|
|
accessToken, ok := auth.GetGlobalTokenCache().Get(did)
|
|
if !ok {
|
|
fmt.Printf("DEBUG [registry/middleware]: No cached access token found for DID=%s (neither OAuth nor Basic Auth)\n", did)
|
|
accessToken = "" // Will fail on manifest push, but let it try
|
|
} else {
|
|
fmt.Printf("DEBUG [registry/middleware]: Using Basic Auth access token for DID=%s (length=%d)\n", did, len(accessToken))
|
|
}
|
|
atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
|
|
}
|
|
|
|
// IMPORTANT: Use only the image name (not identity/image) for ATProto storage
|
|
// ATProto records are scoped to the user's DID, so we don't need the identity prefix
|
|
// Example: "evan.jarrett.net/debian" -> store as "debian"
|
|
repositoryName := imageName
|
|
|
|
// Cache key is DID + repository name
|
|
cacheKey := did + ":" + repositoryName
|
|
|
|
// Check cache first
|
|
if cached, ok := nr.repositories.Load(cacheKey); ok {
|
|
return cached.(*storage.RoutingRepository), nil
|
|
}
|
|
|
|
// Create routing repository - routes manifests to ATProto, blobs to hold service
|
|
// The registry is stateless - no local storage is used
|
|
// Pass storage endpoint and DID as parameters (can't use context as it gets lost)
|
|
routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, storageEndpoint, did, globalDatabase)
|
|
|
|
// Cache the repository
|
|
nr.repositories.Store(cacheKey, routingRepo)
|
|
|
|
return routingRepo, nil
|
|
}
|
|
|
|
// Repositories delegates to underlying namespace
|
|
func (nr *NamespaceResolver) Repositories(ctx context.Context, repos []string, last string) (int, error) {
|
|
return nr.Namespace.Repositories(ctx, repos, last)
|
|
}
|
|
|
|
// Blobs delegates to underlying namespace
|
|
func (nr *NamespaceResolver) Blobs() distribution.BlobEnumerator {
|
|
return nr.Namespace.Blobs()
|
|
}
|
|
|
|
// BlobStatter delegates to underlying namespace
|
|
func (nr *NamespaceResolver) BlobStatter() distribution.BlobStatter {
|
|
return nr.Namespace.BlobStatter()
|
|
}
|
|
|
|
// findStorageEndpoint determines which hold endpoint 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
|
|
// Returns the storage endpoint URL, or empty string if none configured
|
|
func (nr *NamespaceResolver) findStorageEndpoint(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
|
|
profile, err := atproto.GetProfile(ctx, client)
|
|
if err != nil {
|
|
// Error reading profile (not a 404) - log and continue
|
|
fmt.Printf("WARNING: failed to read profile for %s: %v\n", did, err)
|
|
}
|
|
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
// Profile exists with defaultHold set - use it
|
|
return profile.DefaultHold
|
|
}
|
|
|
|
// 2. 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
|
|
}
|
|
|
|
// Find the first hold record
|
|
for _, record := range records {
|
|
var holdRecord atproto.HoldRecord
|
|
if err := json.Unmarshal(record.Value, &holdRecord); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Return the endpoint from the first hold
|
|
if holdRecord.Endpoint != "" {
|
|
return holdRecord.Endpoint
|
|
}
|
|
}
|
|
|
|
// 3. No profile defaultHold and no own hold records - use AppView default
|
|
return nr.defaultStorageEndpoint
|
|
}
|