A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

better handling for io.atcr.repo.page

+231 -158
+1 -1
lexicons/io/atcr/repo/page.json
··· 24 24 "type": "blob", 25 25 "description": "Repository avatar/icon image.", 26 26 "accept": ["image/png", "image/jpeg", "image/webp"], 27 - "maxSize": 1000000 27 + "maxSize": 3000000 28 28 }, 29 29 "createdAt": { 30 30 "type": "string",
+6 -25
pkg/appview/middleware/registry.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "fmt" 7 6 "log/slog" 8 7 "net/http" ··· 16 15 "github.com/distribution/distribution/v3/registry/storage/driver" 17 16 "github.com/distribution/reference" 18 17 18 + "atcr.io/pkg/appview/readme" 19 19 "atcr.io/pkg/appview/storage" 20 20 "atcr.io/pkg/atproto" 21 21 "atcr.io/pkg/auth" ··· 208 208 database storage.DatabaseMetrics // Metrics database (copied from global on init) 209 209 authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init) 210 210 validationCache *validationCache // Request-level service token cache 211 + readmeFetcher *readme.Fetcher // README fetcher for repo pages 211 212 } 212 213 213 214 // initATProtoResolver initializes the name resolution middleware ··· 242 243 database: globalDatabase, 243 244 authorizer: globalAuthorizer, 244 245 validationCache: newValidationCache(), 246 + readmeFetcher: readme.NewFetcher(), 245 247 }, nil 246 248 } 247 249 ··· 458 460 Database: nr.database, 459 461 Authorizer: nr.authorizer, 460 462 Refresher: nr.refresher, 463 + ReadmeFetcher: nr.readmeFetcher, 461 464 } 462 465 463 466 return storage.NewRoutingRepository(repo, registryCtx), nil ··· 481 484 // findHoldDID determines which hold DID to use for blob storage 482 485 // Priority order: 483 486 // 1. User's sailor profile defaultHold (if set) 484 - // 2. User's own hold record (io.atcr.hold) 485 - // 3. AppView's default hold DID 487 + // 2. AppView's default hold DID 486 488 // Returns a hold DID (e.g., "did:web:hold01.atcr.io"), or empty string if none configured 487 489 func (nr *NamespaceResolver) findHoldDID(ctx context.Context, did, pdsEndpoint string) string { 488 490 // Create ATProto client (without auth - reading public records) ··· 508 510 return profile.DefaultHold 509 511 } 510 512 511 - // Profile doesn't exist or defaultHold is null/empty 512 - // Check for user's own hold records 513 - records, err := client.ListRecords(ctx, atproto.HoldCollection, 10) 514 - if err != nil { 515 - // Failed to query holds, use default 516 - return nr.defaultHoldDID 517 - } 518 - 519 - // Find the first hold record 520 - for _, record := range records { 521 - var holdRecord atproto.HoldRecord 522 - if err := json.Unmarshal(record.Value, &holdRecord); err != nil { 523 - continue 524 - } 525 - 526 - // Return the endpoint from the first hold (normalize to DID if URL) 527 - if holdRecord.Endpoint != "" { 528 - return atproto.ResolveHoldDIDFromURL(holdRecord.Endpoint) 529 - } 530 - } 531 - 532 - // No profile defaultHold and no own hold records - use AppView default 513 + // No profile defaultHold - use AppView default 533 514 return nr.defaultHoldDID 534 515 } 535 516
-54
pkg/appview/middleware/registry_test.go
··· 199 199 assert.Equal(t, "did:web:user.hold.io", holdDID, "should use sailor profile's defaultHold") 200 200 } 201 201 202 - // TestFindHoldDID_LegacyHoldRecords tests legacy hold record discovery 203 - func TestFindHoldDID_LegacyHoldRecords(t *testing.T) { 204 - // Start a mock PDS server that returns hold records 205 - mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 206 - if r.URL.Path == "/xrpc/com.atproto.repo.getRecord" { 207 - // Profile not found 208 - w.WriteHeader(http.StatusNotFound) 209 - return 210 - } 211 - if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" { 212 - // Return hold record 213 - holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true) 214 - recordJSON, _ := json.Marshal(holdRecord) 215 - w.Header().Set("Content-Type", "application/json") 216 - json.NewEncoder(w).Encode(map[string]any{ 217 - "records": []any{ 218 - map[string]any{ 219 - "uri": "at://did:plc:test123/io.atcr.hold/abc123", 220 - "value": json.RawMessage(recordJSON), 221 - }, 222 - }, 223 - }) 224 - return 225 - } 226 - w.WriteHeader(http.StatusNotFound) 227 - })) 228 - defer mockPDS.Close() 229 - 230 - resolver := &NamespaceResolver{ 231 - defaultHoldDID: "did:web:default.atcr.io", 232 - } 233 - 234 - ctx := context.Background() 235 - holdDID := resolver.findHoldDID(ctx, "did:plc:test123", mockPDS.URL) 236 - 237 - // Legacy URL should be converted to DID 238 - assert.Equal(t, "did:web:legacy.hold.io", holdDID, "should use legacy hold record and convert to DID") 239 - } 240 - 241 202 // TestFindHoldDID_Priority tests the priority order 242 203 func TestFindHoldDID_Priority(t *testing.T) { 243 204 // Start a mock PDS server that returns both profile and hold records ··· 248 209 w.Header().Set("Content-Type", "application/json") 249 210 json.NewEncoder(w).Encode(map[string]any{ 250 211 "value": profile, 251 - }) 252 - return 253 - } 254 - if r.URL.Path == "/xrpc/com.atproto.repo.listRecords" { 255 - // Return hold record (should be ignored since profile exists) 256 - holdRecord := atproto.NewHoldRecord("https://legacy.hold.io", "alice", true) 257 - recordJSON, _ := json.Marshal(holdRecord) 258 - w.Header().Set("Content-Type", "application/json") 259 - json.NewEncoder(w).Encode(map[string]any{ 260 - "records": []any{ 261 - map[string]any{ 262 - "uri": "at://did:plc:test123/io.atcr.hold/abc123", 263 - "value": json.RawMessage(recordJSON), 264 - }, 265 - }, 266 212 }) 267 213 return 268 214 }
+5 -3
pkg/appview/storage/context.go
··· 1 1 package storage 2 2 3 3 import ( 4 + "atcr.io/pkg/appview/readme" 4 5 "atcr.io/pkg/atproto" 5 6 "atcr.io/pkg/auth" 6 7 "atcr.io/pkg/auth/oauth" ··· 27 28 AuthMethod string // Auth method used ("oauth" or "app_password") 28 29 29 30 // Shared services (same for all requests) 30 - Database DatabaseMetrics // Metrics tracking database 31 - Authorizer auth.HoldAuthorizer // Hold access authorization 32 - Refresher *oauth.Refresher // OAuth session manager 31 + Database DatabaseMetrics // Metrics tracking database 32 + Authorizer auth.HoldAuthorizer // Hold access authorization 33 + Refresher *oauth.Refresher // OAuth session manager 34 + ReadmeFetcher *readme.Fetcher // README fetcher for repo pages 33 35 }
+218 -12
pkg/appview/storage/manifest_store.go
··· 12 12 "net/http" 13 13 "strings" 14 14 "sync" 15 + "time" 15 16 17 + "atcr.io/pkg/appview/readme" 16 18 "atcr.io/pkg/atproto" 17 19 "github.com/distribution/distribution/v3" 18 20 "github.com/opencontainers/go-digest" ··· 432 434 return 433 435 } 434 436 435 - // Check for relevant annotations that we can use for repo page 436 - description := manifestRecord.Annotations["org.opencontainers.image.description"] 437 - if description == "" { 438 - // No description annotation - nothing to create 439 - return 440 - } 441 - 442 437 // Check if repo page already exists (don't overwrite user's custom content) 443 438 rkey := s.ctx.Repository 444 439 _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey) ··· 454 449 return 455 450 } 456 451 457 - // Create new repo page record from manifest annotations 458 - // Note: Avatar is not extracted from annotations here - that's handled separately 459 - // (would require uploading a blob if annotation contains a URL) 460 - repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, nil) 452 + // Try to fetch README content from external sources 453 + // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source > org.opencontainers.image.description 454 + description := s.fetchReadmeContent(ctx, manifestRecord.Annotations) 455 + 456 + // If no README content could be fetched, fall back to description annotation 457 + if description == "" { 458 + description = manifestRecord.Annotations["org.opencontainers.image.description"] 459 + } 460 + 461 + // Try to fetch and upload icon from io.atcr.icon annotation 462 + var avatarRef *atproto.ATProtoBlobRef 463 + if iconURL := manifestRecord.Annotations["io.atcr.icon"]; iconURL != "" { 464 + avatarRef = s.fetchAndUploadIcon(ctx, iconURL) 465 + } 466 + 467 + // If no description and no icon, nothing to create 468 + if description == "" && avatarRef == nil { 469 + slog.Debug("No README, description, or icon found for repo page", "did", s.ctx.DID, "repository", s.ctx.Repository) 470 + return 471 + } 472 + 473 + // Create new repo page record with description and optional avatar 474 + repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef) 461 475 462 - slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository) 476 + slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository, "descriptionLength", len(description), "hasAvatar", avatarRef != nil) 463 477 464 478 _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage) 465 479 if err != nil { ··· 469 483 470 484 slog.Info("Repo page created successfully", "did", s.ctx.DID, "repository", s.ctx.Repository) 471 485 } 486 + 487 + // fetchReadmeContent attempts to fetch README content from external sources 488 + // Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source 489 + // Returns the raw markdown content, or empty string if not available 490 + func (s *ManifestStore) fetchReadmeContent(ctx context.Context, annotations map[string]string) string { 491 + if s.ctx.ReadmeFetcher == nil { 492 + return "" 493 + } 494 + 495 + // Create a context with timeout for README fetching (don't block push too long) 496 + fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 497 + defer cancel() 498 + 499 + // Priority 1: Direct README URL from io.atcr.readme annotation 500 + if readmeURL := annotations["io.atcr.readme"]; readmeURL != "" { 501 + content, err := s.fetchRawReadme(fetchCtx, readmeURL) 502 + if err != nil { 503 + slog.Debug("Failed to fetch README from io.atcr.readme annotation", "url", readmeURL, "error", err) 504 + } else if content != "" { 505 + slog.Info("Fetched README from io.atcr.readme annotation", "url", readmeURL, "length", len(content)) 506 + return content 507 + } 508 + } 509 + 510 + // Priority 2: Derive README URL from org.opencontainers.image.source 511 + if sourceURL := annotations["org.opencontainers.image.source"]; sourceURL != "" { 512 + // Try main branch first, then master 513 + for _, branch := range []string{"main", "master"} { 514 + readmeURL := readme.DeriveReadmeURL(sourceURL, branch) 515 + if readmeURL == "" { 516 + continue 517 + } 518 + 519 + content, err := s.fetchRawReadme(fetchCtx, readmeURL) 520 + if err != nil { 521 + // Only log non-404 errors (404 is expected when trying main vs master) 522 + if !readme.Is404(err) { 523 + slog.Debug("Failed to fetch README from source URL", "url", readmeURL, "branch", branch, "error", err) 524 + } 525 + continue 526 + } 527 + 528 + if content != "" { 529 + slog.Info("Fetched README from source URL", "sourceURL", sourceURL, "branch", branch, "length", len(content)) 530 + return content 531 + } 532 + } 533 + } 534 + 535 + return "" 536 + } 537 + 538 + // fetchRawReadme fetches raw markdown content from a URL 539 + // Returns the raw markdown (not rendered HTML) for storage in the repo page record 540 + func (s *ManifestStore) fetchRawReadme(ctx context.Context, readmeURL string) (string, error) { 541 + // Use a simple HTTP client to fetch raw content 542 + // We want raw markdown, not rendered HTML (the Fetcher renders to HTML) 543 + req, err := http.NewRequestWithContext(ctx, "GET", readmeURL, nil) 544 + if err != nil { 545 + return "", fmt.Errorf("failed to create request: %w", err) 546 + } 547 + 548 + req.Header.Set("User-Agent", "ATCR-README-Fetcher/1.0") 549 + 550 + client := &http.Client{ 551 + Timeout: 10 * time.Second, 552 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 553 + if len(via) >= 5 { 554 + return fmt.Errorf("too many redirects") 555 + } 556 + return nil 557 + }, 558 + } 559 + 560 + resp, err := client.Do(req) 561 + if err != nil { 562 + return "", fmt.Errorf("failed to fetch URL: %w", err) 563 + } 564 + defer resp.Body.Close() 565 + 566 + if resp.StatusCode != http.StatusOK { 567 + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) 568 + } 569 + 570 + // Limit content size to 100KB (repo page description has 100KB limit in lexicon) 571 + limitedReader := io.LimitReader(resp.Body, 100*1024) 572 + content, err := io.ReadAll(limitedReader) 573 + if err != nil { 574 + return "", fmt.Errorf("failed to read response body: %w", err) 575 + } 576 + 577 + return string(content), nil 578 + } 579 + 580 + // fetchAndUploadIcon fetches an image from a URL and uploads it as a blob to the user's PDS 581 + // Returns the blob reference for use in the repo page record, or nil on error 582 + func (s *ManifestStore) fetchAndUploadIcon(ctx context.Context, iconURL string) *atproto.ATProtoBlobRef { 583 + // Create a context with timeout for icon fetching 584 + fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second) 585 + defer cancel() 586 + 587 + // Fetch the icon 588 + req, err := http.NewRequestWithContext(fetchCtx, "GET", iconURL, nil) 589 + if err != nil { 590 + slog.Debug("Failed to create icon request", "url", iconURL, "error", err) 591 + return nil 592 + } 593 + 594 + req.Header.Set("User-Agent", "ATCR-Icon-Fetcher/1.0") 595 + 596 + client := &http.Client{ 597 + Timeout: 10 * time.Second, 598 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 599 + if len(via) >= 5 { 600 + return fmt.Errorf("too many redirects") 601 + } 602 + return nil 603 + }, 604 + } 605 + 606 + resp, err := client.Do(req) 607 + if err != nil { 608 + slog.Debug("Failed to fetch icon", "url", iconURL, "error", err) 609 + return nil 610 + } 611 + defer resp.Body.Close() 612 + 613 + if resp.StatusCode != http.StatusOK { 614 + slog.Debug("Icon fetch returned non-OK status", "url", iconURL, "status", resp.StatusCode) 615 + return nil 616 + } 617 + 618 + // Validate content type - only allow images 619 + contentType := resp.Header.Get("Content-Type") 620 + mimeType := detectImageMimeType(contentType, iconURL) 621 + if mimeType == "" { 622 + slog.Debug("Icon has unsupported content type", "url", iconURL, "contentType", contentType) 623 + return nil 624 + } 625 + 626 + // Limit icon size to 3MB (matching lexicon maxSize) 627 + limitedReader := io.LimitReader(resp.Body, 3*1024*1024) 628 + iconData, err := io.ReadAll(limitedReader) 629 + if err != nil { 630 + slog.Debug("Failed to read icon data", "url", iconURL, "error", err) 631 + return nil 632 + } 633 + 634 + if len(iconData) == 0 { 635 + slog.Debug("Icon data is empty", "url", iconURL) 636 + return nil 637 + } 638 + 639 + // Upload the icon as a blob to the user's PDS 640 + blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, iconData, mimeType) 641 + if err != nil { 642 + slog.Warn("Failed to upload icon blob", "url", iconURL, "error", err) 643 + return nil 644 + } 645 + 646 + slog.Info("Uploaded icon blob", "url", iconURL, "size", len(iconData), "mimeType", mimeType, "cid", blobRef.Ref.Link) 647 + return blobRef 648 + } 649 + 650 + // detectImageMimeType determines the MIME type for an image 651 + // Uses Content-Type header first, then falls back to extension-based detection 652 + // Only allows types accepted by the lexicon: image/png, image/jpeg, image/webp 653 + func detectImageMimeType(contentType, url string) string { 654 + // Check Content-Type header first 655 + switch { 656 + case strings.HasPrefix(contentType, "image/png"): 657 + return "image/png" 658 + case strings.HasPrefix(contentType, "image/jpeg"): 659 + return "image/jpeg" 660 + case strings.HasPrefix(contentType, "image/webp"): 661 + return "image/webp" 662 + } 663 + 664 + // Fall back to URL extension detection 665 + lowerURL := strings.ToLower(url) 666 + switch { 667 + case strings.HasSuffix(lowerURL, ".png"): 668 + return "image/png" 669 + case strings.HasSuffix(lowerURL, ".jpg"), strings.HasSuffix(lowerURL, ".jpeg"): 670 + return "image/jpeg" 671 + case strings.HasSuffix(lowerURL, ".webp"): 672 + return "image/webp" 673 + } 674 + 675 + // Unknown or unsupported type - reject 676 + return "" 677 + }
-13
pkg/atproto/lexicon.go
··· 18 18 // TagCollection is the collection name for image tags 19 19 TagCollection = "io.atcr.tag" 20 20 21 - // HoldCollection is the collection name for storage holds (BYOS) 22 - HoldCollection = "io.atcr.hold" 23 - 24 21 // HoldCrewCollection is the collection name for hold crew (membership) - LEGACY BYOS model 25 22 // Stored in owner's PDS for BYOS holds 26 23 HoldCrewCollection = "io.atcr.hold.crew" ··· 313 310 CreatedAt time.Time `json:"createdAt"` 314 311 } 315 312 316 - // NewHoldRecord creates a new hold record 317 - func NewHoldRecord(endpoint, owner string, public bool) *HoldRecord { 318 - return &HoldRecord{ 319 - Type: HoldCollection, 320 - Endpoint: endpoint, 321 - Owner: owner, 322 - Public: public, 323 - CreatedAt: time.Now(), 324 - } 325 - } 326 313 327 314 // SailorProfileRecord represents a user's profile with registry preferences 328 315 // Stored in the user's PDS to configure default hold and other settings
-50
pkg/atproto/lexicon_test.go
··· 452 452 } 453 453 } 454 454 455 - func TestNewHoldRecord(t *testing.T) { 456 - tests := []struct { 457 - name string 458 - endpoint string 459 - owner string 460 - public bool 461 - }{ 462 - { 463 - name: "public hold", 464 - endpoint: "https://hold1.example.com", 465 - owner: "did:plc:alice123", 466 - public: true, 467 - }, 468 - { 469 - name: "private hold", 470 - endpoint: "https://hold2.example.com", 471 - owner: "did:plc:bob456", 472 - public: false, 473 - }, 474 - } 475 - 476 - for _, tt := range tests { 477 - t.Run(tt.name, func(t *testing.T) { 478 - before := time.Now() 479 - record := NewHoldRecord(tt.endpoint, tt.owner, tt.public) 480 - after := time.Now() 481 - 482 - if record.Type != HoldCollection { 483 - t.Errorf("Type = %v, want %v", record.Type, HoldCollection) 484 - } 485 - 486 - if record.Endpoint != tt.endpoint { 487 - t.Errorf("Endpoint = %v, want %v", record.Endpoint, tt.endpoint) 488 - } 489 - 490 - if record.Owner != tt.owner { 491 - t.Errorf("Owner = %v, want %v", record.Owner, tt.owner) 492 - } 493 - 494 - if record.Public != tt.public { 495 - t.Errorf("Public = %v, want %v", record.Public, tt.public) 496 - } 497 - 498 - if record.CreatedAt.Before(before) || record.CreatedAt.After(after) { 499 - t.Errorf("CreatedAt = %v, want between %v and %v", record.CreatedAt, before, after) 500 - } 501 - }) 502 - } 503 - } 504 - 505 455 func TestNewSailorProfileRecord(t *testing.T) { 506 456 tests := []struct { 507 457 name string
+1
pkg/auth/oauth/client.go
··· 95 95 fmt.Sprintf("repo:%s", atproto.TagCollection), 96 96 fmt.Sprintf("repo:%s", atproto.StarCollection), 97 97 fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 98 + fmt.Sprintf("repo:%s", atproto.RepoPageCollection), 98 99 ) 99 100 100 101 return scopes