A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go

fix readmes not updating on repository page. attempt to fix not being able to send manifest to hold

evan.jarrett.net 2a795ed5 ec90f43d

verified
Changed files
+95 -85
cmd
appview
pkg
+4
cmd/appview/serve.go
··· 201 201 middleware.SetGlobalAuthorizer(holdAuthorizer) 202 202 fmt.Println("Hold authorizer initialized with database caching") 203 203 204 + // Set global readme cache for middleware 205 + middleware.SetGlobalReadmeCache(readmeCache) 206 + fmt.Println("README cache initialized for manifest push refresh") 207 + 204 208 // Initialize UI routes with OAuth app, refresher, device store, health checker, and readme cache 205 209 uiTemplates, uiRouter := initializeUIRoutes(uiDatabase, uiReadOnlyDB, uiSessionStore, oauthApp, refresher, baseURL, deviceStore, defaultHoldDID, healthChecker, readmeCache) 206 210
+10
pkg/appview/middleware/registry.go
··· 36 36 globalRefresher *oauth.Refresher 37 37 globalDatabase storage.DatabaseMetrics 38 38 globalAuthorizer auth.HoldAuthorizer 39 + globalReadmeCache storage.ReadmeCache 39 40 ) 40 41 41 42 // SetGlobalRefresher sets the OAuth refresher instance during initialization ··· 56 57 globalAuthorizer = authorizer 57 58 } 58 59 60 + // SetGlobalReadmeCache sets the readme cache instance during initialization 61 + // Must be called before the registry starts serving requests 62 + func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) { 63 + globalReadmeCache = readmeCache 64 + } 65 + 59 66 func init() { 60 67 // Register the name resolution middleware 61 68 registrymw.Register("atproto-resolver", initATProtoResolver) ··· 71 78 refresher *oauth.Refresher // OAuth session manager (copied from global on init) 72 79 database storage.DatabaseMetrics // Metrics database (copied from global on init) 73 80 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 81 + readmeCache storage.ReadmeCache // README cache (copied from global on init) 74 82 } 75 83 76 84 // initATProtoResolver initializes the name resolution middleware ··· 101 109 refresher: globalRefresher, 102 110 database: globalDatabase, 103 111 authorizer: globalAuthorizer, 112 + readmeCache: globalReadmeCache, 104 113 }, nil 105 114 } 106 115 ··· 321 330 Database: nr.database, 322 331 Authorizer: nr.authorizer, 323 332 Refresher: nr.refresher, 333 + ReadmeCache: nr.readmeCache, 324 334 } 325 335 routingRepo := storage.NewRoutingRepository(repo, registryCtx) 326 336
+6
pkg/appview/static/css/style.css
··· 1707 1707 border: 1px solid var(--border); 1708 1708 border-radius: 8px; 1709 1709 padding: 2rem; 1710 + min-width: 0; 1711 + box-sizing: border-box; 1710 1712 } 1711 1713 1712 1714 .readme-section h2 { ··· 1717 1719 1718 1720 .readme-content { 1719 1721 overflow-wrap: break-word; 1722 + max-width: 100%; 1723 + box-sizing: border-box; 1720 1724 } 1721 1725 1722 1726 .repo-sidebar { ··· 1814 1818 border-radius: 6px; 1815 1819 overflow-x: auto; 1816 1820 margin-bottom: 1rem; 1821 + max-width: 100%; 1822 + box-sizing: border-box; 1817 1823 } 1818 1824 1819 1825 .markdown-body pre code {
+12 -3
pkg/appview/storage/context.go
··· 1 1 package storage 2 2 3 3 import ( 4 + "context" 5 + 4 6 "atcr.io/pkg/atproto" 5 7 "atcr.io/pkg/auth" 6 8 "atcr.io/pkg/auth/oauth" ··· 10 12 type DatabaseMetrics interface { 11 13 IncrementPullCount(did, repository string) error 12 14 IncrementPushCount(did, repository string) error 15 + } 16 + 17 + // ReadmeCache interface for README content caching 18 + type ReadmeCache interface { 19 + Get(ctx context.Context, url string) (string, error) 20 + Invalidate(url string) error 13 21 } 14 22 15 23 // RegistryContext bundles all the context needed for registry operations ··· 25 33 ATProtoClient *atproto.Client // Authenticated ATProto client for this user 26 34 27 35 // Shared services (same for all requests) 28 - Database DatabaseMetrics // Metrics tracking database 29 - Authorizer auth.HoldAuthorizer // Hold access authorization 30 - Refresher *oauth.Refresher // OAuth session manager 36 + Database DatabaseMetrics // Metrics tracking database 37 + Authorizer auth.HoldAuthorizer // Hold access authorization 38 + Refresher *oauth.Refresher // OAuth session manager 39 + ReadmeCache ReadmeCache // README content cache 31 40 }
+54 -16
pkg/appview/storage/manifest_store.go
··· 10 10 "maps" 11 11 "net/http" 12 12 "strings" 13 + "time" 13 14 14 15 "atcr.io/pkg/atproto" 15 16 "github.com/distribution/distribution/v3" 16 17 "github.com/opencontainers/go-digest" 17 18 ) 18 19 19 - // HoldNotifier interface for notifying holds about manifest uploads 20 - type HoldNotifier interface { 21 - GetServiceToken(ctx context.Context, userDID, audienceDID string) (string, error) 22 - } 23 - 24 20 // ManifestStore implements distribution.ManifestService 25 21 // It stores manifests in ATProto as records 26 22 type ManifestStore struct { 27 23 ctx *RegistryContext // Context with user/hold info 28 - notifier HoldNotifier // OAuth refresher for getting service tokens 29 24 lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull) 30 25 blobStore distribution.BlobStore // Blob store for fetching config during push 31 26 } 32 27 33 28 // NewManifestStore creates a new ATProto-backed manifest store 34 - func NewManifestStore(ctx *RegistryContext, notifier HoldNotifier, blobStore distribution.BlobStore) *ManifestStore { 29 + func NewManifestStore(ctx *RegistryContext, blobStore distribution.BlobStore) *ManifestStore { 35 30 return &ManifestStore{ 36 31 ctx: ctx, 37 - notifier: notifier, 38 32 blobStore: blobStore, 39 33 } 40 34 } ··· 195 189 196 190 // Notify hold about manifest upload (for layer tracking and Bluesky posts) 197 191 // Do this asynchronously to avoid blocking the push 198 - if tag != "" && s.notifier != nil && s.ctx.Handle != "" { 192 + if tag != "" && s.ctx.ServiceToken != "" && s.ctx.Handle != "" { 199 193 go func() { 200 194 if err := s.notifyHoldAboutManifest(context.Background(), manifestRecord, tag, dgst.String()); err != nil { 201 195 fmt.Printf("WARNING: Failed to notify hold about manifest: %v\n", err) 202 196 } 203 197 }() 204 198 } 199 + 200 + // Refresh README cache asynchronously if manifest has io.atcr.readme annotation 201 + // This ensures fresh README content is available on repository pages 202 + go func() { 203 + s.refreshReadmeCache(context.Background(), manifestRecord) 204 + }() 205 205 206 206 return dgst, nil 207 207 } ··· 287 287 // notifyHoldAboutManifest notifies the hold service about a manifest upload 288 288 // This enables the hold to create layer records and Bluesky posts 289 289 func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest string) error { 290 - // Skip if no notifier configured 291 - if s.notifier == nil { 290 + // Skip if no service token configured (e.g., anonymous pulls) 291 + if s.ctx.ServiceToken == "" { 292 292 return nil 293 293 } 294 294 ··· 299 299 return fmt.Errorf("failed to resolve hold DID %s: %w", s.ctx.HoldDID, err) 300 300 } 301 301 302 - // Get service token from user's PDS for hold authentication 303 - serviceToken, err := s.notifier.GetServiceToken(ctx, s.ctx.DID, s.ctx.HoldDID) 304 - if err != nil { 305 - return fmt.Errorf("failed to get service token: %w", err) 306 - } 302 + // Use service token from middleware (already cached and validated) 303 + serviceToken := s.ctx.ServiceToken 307 304 308 305 // Build notification request 309 306 notifyReq := map[string]any{ ··· 370 367 371 368 return nil 372 369 } 370 + 371 + // refreshReadmeCache refreshes the README cache for this manifest if it has io.atcr.readme annotation 372 + // This should be called asynchronously after manifest push to keep README content fresh 373 + func (s *ManifestStore) refreshReadmeCache(ctx context.Context, manifestRecord *atproto.ManifestRecord) { 374 + // Skip if no README cache configured 375 + if s.ctx.ReadmeCache == nil { 376 + return 377 + } 378 + 379 + // Skip if no annotations or no README URL 380 + if manifestRecord.Annotations == nil { 381 + return 382 + } 383 + 384 + readmeURL, ok := manifestRecord.Annotations["io.atcr.readme"] 385 + if !ok || readmeURL == "" { 386 + return 387 + } 388 + 389 + fmt.Printf("INFO: Refreshing README cache for %s/%s from %s\n", s.ctx.DID, s.ctx.Repository, readmeURL) 390 + 391 + // Invalidate the cached entry first 392 + if err := s.ctx.ReadmeCache.Invalidate(readmeURL); err != nil { 393 + fmt.Printf("WARNING: Failed to invalidate README cache for %s: %v\n", readmeURL, err) 394 + // Continue anyway - Get() will still fetch fresh content 395 + } 396 + 397 + // Fetch fresh content to populate cache 398 + // Use context with timeout to avoid hanging on slow/dead URLs 399 + ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Second) 400 + defer cancel() 401 + 402 + _, err := s.ctx.ReadmeCache.Get(ctxWithTimeout, readmeURL) 403 + if err != nil { 404 + fmt.Printf("WARNING: Failed to refresh README cache for %s: %v\n", readmeURL, err) 405 + // Not a critical error - cache will be refreshed on next page view 406 + return 407 + } 408 + 409 + fmt.Printf("INFO: README cache refreshed successfully for %s\n", readmeURL) 410 + }
+8 -8
pkg/appview/storage/manifest_store_test.go
··· 141 141 db := &mockDatabaseMetrics{} 142 142 143 143 ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", db) 144 - store := NewManifestStore(ctx, nil, blobStore) 144 + store := NewManifestStore(ctx, blobStore) 145 145 146 146 if store.ctx.Repository != "myapp" { 147 147 t.Errorf("repository = %v, want myapp", store.ctx.Repository) ··· 189 189 t.Run(tt.name, func(t *testing.T) { 190 190 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 191 191 ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil) 192 - store := NewManifestStore(ctx, nil, nil) 192 + store := NewManifestStore(ctx, nil) 193 193 194 194 // Simulate what happens in Get() when parsing a manifest record 195 195 var manifestRecord atproto.ManifestRecord ··· 264 264 // Create manifest store 265 265 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 266 266 ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil) 267 - store := NewManifestStore(ctx, nil, blobStore) 267 + store := NewManifestStore(ctx, blobStore) 268 268 269 269 // Extract labels 270 270 labels, err := store.extractConfigLabels(context.Background(), configDigest.String()) ··· 304 304 305 305 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 306 306 ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil) 307 - store := NewManifestStore(ctx, nil, blobStore) 307 + store := NewManifestStore(ctx, blobStore) 308 308 309 309 labels, err := store.extractConfigLabels(context.Background(), configDigest.String()) 310 310 if err != nil { ··· 322 322 blobStore := newMockBlobStore() 323 323 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 324 324 ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil) 325 - store := NewManifestStore(ctx, nil, blobStore) 325 + store := NewManifestStore(ctx, blobStore) 326 326 327 327 _, err := store.extractConfigLabels(context.Background(), "invalid-digest") 328 328 if err == nil { ··· 341 341 342 342 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 343 343 ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil) 344 - store := NewManifestStore(ctx, nil, blobStore) 344 + store := NewManifestStore(ctx, blobStore) 345 345 346 346 _, err := store.extractConfigLabels(context.Background(), configDigest.String()) 347 347 if err == nil { ··· 354 354 db := &mockDatabaseMetrics{} 355 355 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 356 356 ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", db) 357 - store := NewManifestStore(ctx, nil, nil) 357 + store := NewManifestStore(ctx, nil) 358 358 359 359 if store.ctx.Database != db { 360 360 t.Error("ManifestStore should store database reference") ··· 368 368 func TestManifestStore_WithoutMetrics(t *testing.T) { 369 369 client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token") 370 370 ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", nil) 371 - store := NewManifestStore(ctx, nil, nil) 371 + store := NewManifestStore(ctx, nil) 372 372 373 373 if store.ctx.Database != nil { 374 374 t.Error("ManifestStore should accept nil database")
+1 -58
pkg/appview/storage/routing_repository.go
··· 6 6 7 7 import ( 8 8 "context" 9 - "encoding/json" 10 9 "fmt" 11 - "io" 12 - "net/http" 13 10 "time" 14 11 15 - "atcr.io/pkg/auth/oauth" 16 12 "github.com/distribution/distribution/v3" 17 13 ) 18 14 ··· 25 21 blobStore *ProxyBlobStore // Cached blob store instance 26 22 } 27 23 28 - // refresherAdapter adapts the oauth.Refresher to implement atproto.HoldNotifier 29 - type refresherAdapter struct { 30 - refresher *oauth.Refresher 31 - pdsEndpoint string 32 - } 33 - 34 - // GetServiceToken implements atproto.HoldNotifier 35 - func (r *refresherAdapter) GetServiceToken(ctx context.Context, userDID, audienceDID string) (string, error) { 36 - // Get OAuth session for the user 37 - session, err := r.refresher.GetSession(ctx, userDID) 38 - if err != nil { 39 - return "", fmt.Errorf("failed to get OAuth session: %w", err) 40 - } 41 - 42 - // Build service auth URL 43 - serviceAuthURL := fmt.Sprintf("%s/xrpc/com.atproto.server.getServiceAuth?aud=%s", r.pdsEndpoint, audienceDID) 44 - 45 - req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil) 46 - if err != nil { 47 - return "", fmt.Errorf("failed to create request: %w", err) 48 - } 49 - 50 - // Use session's DoWithAuth to handle OAuth authentication automatically 51 - resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth") 52 - if err != nil { 53 - return "", fmt.Errorf("failed to request service token: %w", err) 54 - } 55 - defer resp.Body.Close() 56 - 57 - if resp.StatusCode != http.StatusOK { 58 - body, _ := io.ReadAll(resp.Body) 59 - return "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, body) 60 - } 61 - 62 - var result struct { 63 - Token string `json:"token"` 64 - } 65 - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 66 - return "", fmt.Errorf("failed to decode response: %w", err) 67 - } 68 - 69 - return result.Token, nil 70 - } 71 - 72 24 // NewRoutingRepository creates a new routing repository 73 25 func NewRoutingRepository(baseRepo distribution.Repository, ctx *RegistryContext) *RoutingRepository { 74 26 return &RoutingRepository{ ··· 84 36 // Ensure blob store is created first (needed for label extraction during push) 85 37 blobStore := r.Blobs(ctx) 86 38 87 - // Wrap the Refresher in an adapter to implement HoldNotifier 88 - var notifier HoldNotifier 89 - if r.Ctx.Refresher != nil { 90 - notifier = &refresherAdapter{ 91 - refresher: r.Ctx.Refresher, 92 - pdsEndpoint: r.Ctx.PDSEndpoint, 93 - } 94 - } 95 - 96 - r.manifestStore = NewManifestStore(r.Ctx, notifier, blobStore) 39 + r.manifestStore = NewManifestStore(r.Ctx, blobStore) 97 40 } 98 41 99 42 // After any manifest operation, cache the hold DID for blob fetches